From ceba53edd75570b7f8854852d35563bd97d07eb2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:22:50 +0800 Subject: [PATCH 01/32] fix(fs): observe absence before guarded recreation --- ...06-26-file-context-as-event-gate.i18n.yaml | 4 +- .../2026-06-26-file-context-as-event-gate.md | 36 +++++----- ...026-06-26-file-context-as-event-gate.zh.md | 36 +++++----- ...9-filesystem-absence-observation.i18n.yaml | 6 ++ ...26-08-09-filesystem-absence-observation.md | 36 ++++++++++ ...08-09-filesystem-absence-observation.zh.md | 36 ++++++++++ .../2026-08-03-fs-tool-error-remedy.i18n.yaml | 4 +- .../2026-08-03-fs-tool-error-remedy.md | 2 +- .../2026-08-03-fs-tool-error-remedy.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/subsystems/filesystem.i18n.yaml | 4 +- docs/subsystems/filesystem.md | 50 ++++++++------ docs/subsystems/filesystem.zh.md | 50 ++++++++------ docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 4 +- docs/tool-catalog.zh.md | 4 +- examples/acp-agent/tests/acp.snapshot.ts | 1 + .../snapshots/fs-delete-recreate/input.json | 7 ++ .../fs-delete-recreate/session.jsonl | 62 +++++++++++++++++ .../fs-delete-recreate/stdout.expected.jsonl | 4 ++ .../fs-delete-recreate/workspace/deleted.txt | 1 + packages/e2b/fs-e2b/README.i18n.yaml | 4 +- packages/e2b/fs-e2b/README.md | 6 +- packages/e2b/fs-e2b/README.zh.md | 6 +- packages/e2b/fs-e2b/src/index.ts | 34 +++++++++- packages/e2b/fs-e2b/tests/filesystem.spec.ts | 67 +++++++++++++++++++ packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 4 +- packages/fs/fs-local/README.zh.md | 4 +- packages/fs/fs-local/src/fsio.ts | 31 +++++++-- packages/fs/fs-local/src/index.ts | 9 ++- packages/fs/fs-local/tests/filesystem.spec.ts | 10 +++ packages/fs/fs-local/tests/fsio.spec.ts | 11 +++ packages/fs/fs-policy/README.i18n.yaml | 4 +- packages/fs/fs-policy/README.md | 12 ++-- packages/fs/fs-policy/README.zh.md | 12 ++-- packages/fs/fs-policy/src/index.ts | 56 +++++++++------- packages/fs/fs-policy/tests/policy.spec.ts | 53 ++++++++++++--- packages/fs/fs/README.i18n.yaml | 4 +- packages/fs/fs/README.md | 6 +- packages/fs/fs/README.zh.md | 6 +- packages/fs/fs/src/index.ts | 17 +++-- packages/fs/fs/src/invariant.ts | 15 ++++- packages/fs/fs/src/types.ts | 9 +++ packages/fs/fs/tests/invariant.spec.ts | 21 ++++-- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 4 +- packages/fs/tool-fs/README.zh.md | 4 +- packages/fs/tool-fs/src/edit.ts | 4 +- packages/fs/tool-fs/src/read.ts | 11 +-- packages/fs/tool-fs/src/write.ts | 4 +- packages/fs/tool-fs/tests/integration.spec.ts | 54 +++++++++------ .../tool-str-replace-editor/README.i18n.yaml | 4 +- packages/fs/tool-str-replace-editor/README.md | 2 +- .../fs/tool-str-replace-editor/README.zh.md | 2 +- .../fs/tool-str-replace-editor/src/index.ts | 9 +-- .../tests/tools.spec.ts | 29 ++++++++ .../tool-cordis/src/api-catalog.ts | 10 +-- packages/skill/skill-local/src/index.ts | 2 +- .../skill-local/tests/skill-local.spec.ts | 8 +-- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 4 +- scripts/type-equiv.manifest.json | 5 ++ 68 files changed, 694 insertions(+), 249 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md create mode 100644 examples/acp-agent/tests/snapshots/fs-delete-recreate/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-delete-recreate/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-delete-recreate/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-delete-recreate/workspace/deleted.txt diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index ffa6f48889..a8acbaf10e 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.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-06-26-file-context-as-event-gate.md -2026-06-26-file-context-as-event-gate.md: df1a43e252cff02b287497210664c3d14dcd35a9 -2026-06-26-file-context-as-event-gate.zh.md: 3d784c9e150430c8054f6b08ccc5c66fa85e09b4 +2026-06-26-file-context-as-event-gate.md: 353beb2498a310eee8cc794fa5edc22915fd6ad0 +2026-06-26-file-context-as-event-gate.zh.md: 37d7c1e4d971896a0e6ddeb42650635b8f755ca0 diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md index df1a43e252..353beb2498 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -33,14 +33,16 @@ provider dsh-fs-local local implementation of ctx.fs The model is additive: bare `ctx.fs` performs atomic, unconstrained text I/O, while `dsh-fs-policy` adds observed state, read-before-edit, and version guards. Removing the policy therefore leaves the tools usable but unconstrained. Shipped agent configs load the policy; the bare mode exists to keep policy optional at the service boundary, not as the normal deployment stance. +The [filesystem absence-observation follow-up](../bug-fix/2026-08-09-filesystem-absence-observation.md) refines the recording payload from a success-only version to explicit present/absent state and requires guarded creation to publish without replacement. The event-gate ownership and no-I/O policy boundary remain unchanged. + `dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`. ## The policy is enforced by provider CAS, not by `dsh-fs-policy` stat `dsh-fs-policy` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness: -- "Have you read this file?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. No record ⇒ `FS_NOT_OBSERVED`. -- "Is the version you read still current?" is decided **inside `ctx.fs.editText`/`writeText`**, in the same atomic lock that performs the read-match-rename. `dsh-fs-policy` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on. +- "What did this owner last observe?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. No record means unseen; an absent record permits only guarded creation; a present record carries the replacement/edit basis. +- "Is the version still current, or is the create target still absent?" is decided **inside the provider's atomic mutation boundary**. `dsh-fs-policy` supplies `replaceIfVersion` or `createIfAbsent`; the provider raises `FS_STALE_VERSION` for a moved version and `FS_NOT_OBSERVED` when a guarded create loses to another creator. This is deliberate. If `dsh-fs-policy` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-fs-policy` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-fs-policy` only chooses the basis (`vObserved`) and gates on prior observation. @@ -68,14 +70,14 @@ The `FsWriteIntent` union itself does not change — the third "unconditional" s The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-fs-policy` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-fs-policy` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin. -These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). +These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsObservation`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). **The two `fs/*` decision events are single-slot, first-wins waterfalls.** `dsh-fs-policy` returns without calling `next()`, so it owns the slot in the default deployment; a listener registered earlier or with `prepend` would replace that policy. Permission, audit, and sandbox concerns remain on the composable `tools/execute` waterfall. The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider contract never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. ```ts -import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' interface Events { /** @@ -95,14 +97,14 @@ interface Events { */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> /** - * Record that an actor observed a target at a version, after a successful - * read/write/edit. Fire-and-forget (plain emit). Listeners MUST be + * Record that an actor observed a target as present at a version or absent. + * Fire-and-forget (plain emit). Listeners MUST be * synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap * write); the tool does not guard the emit, so a throwing listener surfaces as * the tool's isError result. No listener ⇒ nothing recorded. * @mode emit */ - 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void + 'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void } ``` @@ -118,23 +120,23 @@ The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte un `stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats: -- **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then an `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock). -- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-fs-policy`. -- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path. +- **read** — one `stat`; a metadata miss emits `{ kind: 'absent' }` before returning `FS_NOT_FOUND`, while a file routes through `readText`/`streamText`, `buildWindow`, then emits `{ kind: 'present', version: info.version }`. The post-read confirming `stat` from the old `fileContext.read` stays dropped; a writer racing between the routing stat and the read can at worst make a later guarded edit spuriously stale. +- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then emit the present outcome version. **Zero stat in the tool** with or without `dsh-fs-policy`. +- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then emit the present outcome version. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent on the bare path, the provider reports `FS_STALE_VERSION`; the policy returns `FS_NOT_FOUND` directly when it already holds an absent observation. The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment. -**`fs/observed` fires after a successful operation.** Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener would report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract. +**`fs/observed` fires after a successful operation and after a metadata probe confirms absence.** Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener can replace the pending read error or report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract. ## Policy plugin contract (`dsh-fs-policy`) -`dsh-fs-policy` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`. +`dsh-fs-policy` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`. -- `fs/write-intent` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot. -- `fs/edit-intent` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`. -- `fs/observed` listener: `record(owner, key, version)`. +- `fs/write-intent` listener: unseen/absent ⇒ `createIfAbsent`; present ⇒ `replaceIfVersion`. It does NOT call `next()`: it fully owns the single decision slot. +- `fs/edit-intent` listener: unseen ⇒ `FS_NOT_OBSERVED`; absent ⇒ `FS_NOT_FOUND`; present ⇒ its version guard. It does NOT call `next()`. +- `fs/observed` listener: record the present/absent discriminated value. -An observed-state entry is the **prior-observation record**: a successful `read`, `write`, OR `edit` all emit `fs/observed` and record `{ version }`, so the entry's presence means "this owner has observed this target at this version", not narrowly "has read it". This is what lets a create-then-edit or edit-then-edit sequence work without an intervening re-read: the mutation refreshes the recorded version to its own result, so the next edit's basis is the version it just produced. `FS_NOT_OBSERVED` rejects only an edit with NO prior observation of any kind. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety). +An observed-state entry is the **prior-observation record**, but its discriminant matters. Successful read/write/edit records present at a version, allowing create-then-edit or edit-then-edit without an intervening read. A read/view that confirms absence replaces any old positive version with absent, allowing only a guarded create; a later successful create replaces it with the new present version. Missing entry alone means unseen and produces `FS_NOT_OBSERVED` for edit. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety). `dsh-fs-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event gate. That is what removes the method coupling from `dsh-tool-fs`. @@ -154,7 +156,7 @@ This amends — does not reverse — [the split-fs-seam Agent Note](../simplific ## Verification -Tests pin both paths: without `dsh-fs-policy`, the root tool plugin boots against `dsh-fs-local`, and read, create, overwrite, and unread edit succeed; with the policy, unread edit returns `FS_NOT_OBSERVED` and unread overwrite is gated by `createIfAbsent`. A later intent listener is not reached after the policy decides. Stale edits fail through provider CAS while the policy performs no `stat`; the tool budgets remain one `stat` for read and zero for write or edit on either path. Model-facing schemas remain byte-for-byte unchanged, so snapshots do not change. +Tests pin both paths: without `dsh-fs-policy`, the root tool plugin boots against `dsh-fs-local`, and read, create, overwrite, and unread edit succeed; with the policy, unread edit returns `FS_NOT_OBSERVED` and unread overwrite is gated by `createIfAbsent`. A later intent listener is not reached after the policy decides. Stale edits fail through provider CAS while the policy performs no `stat`; the tool budgets remain one `stat` for read and zero for write or edit on either path. The deletion recovery path is also assembled: stale mutation, missing reread, guarded recreation. Model-facing schemas remain byte-for-byte unchanged, while the recovered result transcript changes. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index 3d784c9e15..37d7c1e4d9 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -33,14 +33,16 @@ provider dsh-fs-local local implementation of ctx.fs 该模型是叠加式的:裸 `ctx.fs` 执行原子化、无约束的文本 I/O,而 `dsh-fs-policy` 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。 +[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门禁归属与无 I/O 策略边界保持不变。 + `dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 和 `tools`/`systemPrompt`。 ## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` 的 stat `dsh-fs-policy` 强制执行「你必须基于你读到的版本来写入/编辑」,**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性: -- 「你读过这个文件吗?」是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`。 -- 「你读到的版本是否仍为最新?」由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一个原子锁中完成。`dsh-fs-policy` 将 `vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`。 +- 「该所有者最近观测到了什么?」是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录表示未见;缺失记录只允许带防护的创建;存在记录携带替换/编辑基准。 +- 「版本是否仍然有效,或者创建目标是否仍然缺失?」由**提供方的原子变更边界内部**决定。`dsh-fs-policy` 提供 `replaceIfVersion` 或 `createIfAbsent`;对于已经变化的版本,提供方抛出 `FS_STALE_VERSION`;带防护的创建若败给另一个创建者,则抛出 `FS_NOT_OBSERVED`。 这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall(瀑布式事件)处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O;「必须基于最近一次读取」的保证由 CAS *实现*,`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。 @@ -68,14 +70,14 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion 事件定义在 `@deepseek-ai/dsh-fs` 中,而非 `dsh-fs-policy` 中。这是解耦约定所迫:`dsh-tool-fs` 是发射方,因此它必须引用事件类型,且即使 `dsh-fs-policy` 不再提供方法服务,它也必须能编译通过。`dsh-fs` 是 `dsh-tool-fs` 和 `dsh-fs-policy` 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。 -这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsWriteIntent`)加一个不透明的 actor——不携带面向模型的概念(行窗口、行号或渲染后的页脚不会泄漏到此层)。 +这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsObservation`、`FsWriteIntent`)加一个不透明的 actor——不携带面向模型的概念(行窗口、行号或渲染后的页脚不会泄漏到此层)。 **两个 `fs/*` 决策事件是单槽、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 直接返回,因此在默认部署中它占据该槽位;更早注册或使用 `prepend` 的监听器会替代该策略。权限、审计和沙箱关注点仍留在可组合的 `tools/execute` waterfall 上。 actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方约定从不读取或收窄它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其在监听器中将 `object` actor 收窄为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它不拥有策略层的运行时 owner 结构。 ```ts -import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' interface Events { /** @@ -95,14 +97,14 @@ interface Events { */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> /** - * Record that an actor observed a target at a version, after a successful - * read/write/edit. Fire-and-forget (plain emit). Listeners MUST be + * Record that an actor observed a target as present at a version or absent. + * Fire-and-forget (plain emit). Listeners MUST be * synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap * write); the tool does not guard the emit, so a throwing listener surfaces as * the tool's isError result. No listener ⇒ nothing recorded. * @mode emit */ - 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void + 'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void } ``` @@ -118,23 +120,23 @@ interface Events { 通过让 waterfall 惰性产出期望值来最小化 `stat` 预算——裸默认返回 `undefined`(无守卫),从不 stat: -- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读后确认的 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑误报 `FS_STALE_VERSION`(为安全起见拒绝写入:模型会重新读取;由于 `editText` 会在其锁内复查,模型绝不会基于错误版本写入)。 -- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。无论是否有 `dsh-fs-policy`,**工具内零 stat**。 -- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。 +- **read**——一次 `stat`;元数据未命中时,在返回 `FS_NOT_FOUND` 前 emit `{ kind: 'absent' }`;目标为文件时,则依次执行 `readText`/`streamText`、`buildWindow`,再 emit `{ kind: 'present', version: info.version }`。旧 `fileContext.read` 中读后确认的 `stat` 仍保持移除;在路由 stat 和读取之间竞争的写入者最多只能使后续带防护的编辑误报陈旧。 +- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,再 emit 表示存在的结果版本。无论是否有 `dsh-fs-policy`,**工具内零 stat**。 +- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,再 emit 表示存在的结果版本。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果裸路径上的目标不存在,提供方报告 `FS_STALE_VERSION`;策略已持有缺失观测时,则直接返回 `FS_NOT_FOUND`。 工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,以便 `dsh-fs-policy` 推导其观测状态的 owner。工具不知道策略插件是否存在:它始终在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。 -**`fs/observed` 在操作成功后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器会在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件约定。 +**`fs/observed` 在操作成功后,以及元数据探测确认缺失后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器可能取代待返回的读取错误,或在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件约定。 ## 策略插件约定(`dsh-fs-policy`) -`dsh-fs-policy` 是插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个 disposer 用于 HMR)。它维护观测状态 `WeakMap>`,以及结构化的 owner 推导(将事件中不透明的 `object` actor 收窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。 +`dsh-fs-policy` 是插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个 disposer 用于 HMR)。它维护观测状态 `WeakMap>`,以及结构化的 owner 推导(将事件中不透明的 `object` actor 收窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。 -- `fs/write-intent` 监听器:`prior = getObserved(owner, key)`;返回 `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`。它不调用 `next()`:完全占据单一决策槽位。 -- `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。 -- `fs/observed` 监听器:`record(owner, key, version)`。 +- `fs/write-intent` 监听器:未见/缺失 ⇒ `createIfAbsent`;存在 ⇒ `replaceIfVersion`。它不调用 `next()`:完全占据单一决策槽位。 +- `fs/edit-intent` 监听器:未见 ⇒ `FS_NOT_OBSERVED`;缺失 ⇒ `FS_NOT_FOUND`;存在 ⇒ 返回其版本守卫。同样不调用 `next()`。 +- `fs/observed` 监听器:记录存在/缺失的可辨识值。 -一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着「此 owner 在此版本观测过此目标」,而非狭义的「已读取过」。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作:mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导;dispose 时丢弃所有状态(HMR 安全)。 +一条观测状态条目是**先前观测记录**,但其可辨识字段会影响决策。成功的 read/write/edit 会记录存在状态及版本,使 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作。确认缺失的 read/view 会用缺失状态取代旧的正向版本,因此只允许带防护的创建;随后成功的创建会再用新的存在版本取代缺失状态。只有条目不存在才表示未见,并使 edit 返回 `FS_NOT_OBSERVED`。owner 从 `{ agent?: { session? } }` 结构化推导;dispose 时丢弃所有状态(HMR 安全)。 `dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件门控影响外界。这正是移除 `dsh-tool-fs` 方法耦合的关键。 @@ -154,7 +156,7 @@ interface Events { ## 验证 -测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`,write 或 edit 均为零次。面向模型的 schema 逐字节不变,因此快照不变。 +测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动,read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`,write 或 edit 均为零次。测试也组装了删除恢复路径:陈旧变更、重新读取时确认缺失、带防护的重新创建。面向模型的 schema 逐字节不变,但恢复后的结果 transcript(文本记录)发生变化。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml new file mode 100644 index 0000000000..39e50ffe6b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.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-09-filesystem-absence-observation.md +2026-08-09-filesystem-absence-observation.md: e0c5c8b1845550ac6380ea4b2782cde13cba3ce9 +2026-08-09-filesystem-absence-observation.zh.md: 808698a7cb7a3575ef500dced7a646691d65c424 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md new file mode 100644 index 0000000000..e0c5c8b184 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md @@ -0,0 +1,36 @@ +# Agent Note: Filesystem absence is an observation and guarded creation never replaces + +Status: implemented + +English | [中文](2026-08-09-filesystem-absence-observation.zh.md) + +## Problem + +The event-gated filesystem policy originally records only successful reads and mutations as a target version. If a session reads a file and an external command deletes it, the first guarded mutation correctly fails stale, but the prescribed reread returns `FS_NOT_FOUND` before emitting `fs/observed`. The old positive version therefore remains forever: write keeps choosing `replaceIfVersion`, the provider keeps rejecting the missing target, and the model-facing “re-read the file, then retry” instruction becomes an unrecoverable loop. + +Treating a failed read as permission to create also exposes a second boundary. Both local and E2B providers probe before staging, then historically publish with rename; another process can create the target between those steps and be overwritten even though the caller supplied `createIfAbsent`. An in-process target lock does not protect that cross-process publication race. + +## Decision + +`dsh-fs` owns an explicit observation union: `{ kind: 'present', version: FsVersion } | { kind: 'absent' }`. The `fs/observed` event carries that union. Successful reads and mutations emit present; a `read` or `str_replace_editor view` metadata miss emits absent synchronously before returning `FS_NOT_FOUND`. Other read failures do not manufacture absence. + +`dsh-fs-policy` stores three logical states per owner and target without injecting or calling `ctx.fs`: missing map entry is unseen, `absent` is confirmed absence, and `present(version)` is a replacement/edit basis. Write maps unseen and absent to the existing `createIfAbsent` intent and present to `replaceIfVersion`. Edit maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. A successful create or mutation replaces absence with its produced present version. + +Every provider must enforce `createIfAbsent` at the publication point, not only at its initial probe. `dsh-fs-local` stages and fsyncs in a private sibling directory, then hard-links the staged file to the destination; an existing destination makes the no-replace link fail and preserves the competitor. `dsh-fs-e2b` uses remote `ln` with an explicit created/existing result and derives the committed target version from metadata obtained before the non-cancellable commit. Replacements and bare unconditional writes retain their existing publication paths. + +This decision does not claim cross-process linearizability for `replaceIfVersion`: the provider version check and replacement remain protected only against writers represented by the provider's own lock and detectable metadata. The narrower guarantee is exact and sufficient for absence recovery: guarded creation never clobbers a target that appears before publication. + +## Alternatives considered + +- **Delete the cached version when a read returns not found.** Rejected because it conflates unseen with confirmed absence, cannot give edit the correct `FS_NOT_FOUND` result, and erases the state transition the event is meant to communicate. +- **Have `dsh-fs-policy` call `stat` before choosing an intent.** Rejected because it makes the event-only policy depend on a provider, adds I/O to every decision, and still leaves a TOCTOU gap before publication. +- **Let `replaceIfVersion` create when its target disappeared.** Rejected because a positive observation is evidence for replacement, not creation; silently changing that provider intent would bypass the required missing reread and weaken stale protection. +- **Keep the deleted-target dead end fail-closed.** Rejected because the model-facing recovery instruction is then false and a normal external cleanup cannot be recovered within the session. + +## Consequences + +The first mutation after an unobserved external deletion still fails `FS_STALE_VERSION`; the user or model must follow the existing reread remedy. That missing reread returns `FS_NOT_FOUND` while changing policy state, after which edit remains forbidden and write may recreate the path. If another writer wins the create race, the retry returns `FS_NOT_OBSERVED` and leaves the winner intact. + +The observation payload is a package-owned event contract change, so every producer, listener, invariant, generated Cordis catalog, subsystem document, and both filesystem tool families move together. The policy keeps its one-stat read and zero-stat write/edit budget, owner isolation, disposal behavior, and optional deployment boundary from the [event-gate decision](../architecture/2026-06-26-file-context-as-event-gate.md). + +The assembled filesystem snapshot pins the model-visible recovery chain, while provider tests inject a creator after staging to prove no-clobber publication. The [guarded-mutation remedy decision](../feature/2026-08-03-fs-tool-error-remedy.md) remains the owner of model-facing recovery wording; this note makes its deletion path actionable. diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md new file mode 100644 index 0000000000..808698a7cb --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 文件系统中的缺失是一种观测,带防护的创建绝不执行替换 + +Status: implemented + +[English](2026-08-09-filesystem-absence-observation.md) | 中文 + +## 问题 + +事件门控的文件系统策略最初只把成功读取和变更记录成目标版本。如果某个会话读取文件后,外部命令将其删除,第一次带防护的变更会正确地因陈旧而失败,但按指示执行的重新读取会在发出 `fs/observed` 前返回 `FS_NOT_FOUND`。因此,旧的存在版本会一直保留:写入仍不断选择 `replaceIfVersion`,提供方仍不断拒绝缺失目标,而面向模型的「re-read the file, then retry」指令则形成无法恢复的循环。 + +把一次失败的读取视作创建授权,还会暴露第二个边界。本地与 E2B 提供方都会先探测再暂存,此前随后通过 rename 发布;另一进程可能在两步之间创建目标,即使调用方提供了 `createIfAbsent`,该目标仍会被覆盖。进程内目标锁无法防范这种跨进程发布竞态。 + +## 决策 + +`dsh-fs` 拥有一个显式观测联合类型:`{ kind: 'present', version: FsVersion } | { kind: 'absent' }`。`fs/observed` 事件携带该联合类型。成功的读取与变更发出存在观测;`read` 或 `str_replace_editor view` 的元数据未命中会在返回 `FS_NOT_FOUND` 前同步发出缺失观测。其他读取失败不会产生缺失观测。 + +`dsh-fs-policy` 按所有者与目标存储三种逻辑状态,既不注入也不调用 `ctx.fs`:映射中无条目即未见,`absent` 表示确认缺失,`present(version)` 是替换/编辑基准。写入把未见和缺失映射到现有 `createIfAbsent` 意图,把存在映射到 `replaceIfVersion`。编辑把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。成功创建或变更后,系统会用其产生的存在版本取代缺失状态。 + +每个提供方都必须在发布点执行 `createIfAbsent`,不能只在初始探测时执行。`dsh-fs-local` 在私有同级目录中暂存并执行 fsync,再通过硬链接把暂存文件发布到目标位置;目标已存在时,不替换链接会失败,并保留竞争创建者写入的文件。`dsh-fs-e2b` 使用远程 `ln` 返回明确的已创建/已存在结果,并根据不可取消提交前取得的元数据推导已提交目标的版本。替换操作和裸无条件写入仍沿用现有发布路径。 + +本决策不宣称 `replaceIfVersion` 具有跨进程线性一致性:提供方的版本检查与替换仍只能防范被其自身锁纳入协调的写入方,以及能通过元数据检测到的写入方。更窄的保证边界准确且足以支持缺失恢复:带防护的创建绝不会覆盖在发布前出现的目标。 + +## 曾考虑的替代方案 + +- **读取返回未找到时删除缓存版本。** 不予采用,因为这会混淆未见与确认缺失,无法让 edit 返回正确的 `FS_NOT_FOUND` 结果,还会抹去该事件本应传达的状态转换。 +- **让 `dsh-fs-policy` 在选择意图前调用 `stat`。** 不予采用,因为这会让只依赖事件的策略转而依赖提供方,为每次决策增加 I/O,并且在发布前仍留下 TOCTOU 间隙。 +- **允许 `replaceIfVersion` 在目标消失后执行创建。** 不予采用,因为存在观测是执行替换而非创建的依据;静默改变该提供方意图会绕过必须针对缺失目标执行的重新读取,并削弱陈旧保护。 +- **让删除目标后的死路继续保持 fail-closed。** 不予采用,因为这样会使面向模型的恢复指令失实,而且正常的外部清理操作无法在会话内恢复。 + +## 影响 + +尚未观测到外部删除时,第一次变更仍以 `FS_STALE_VERSION` 失败;用户或模型必须遵循现有的重新读取恢复指令。该次针对缺失目标的重新读取会返回 `FS_NOT_FOUND` 并同时改变策略状态,此后 edit 仍被禁止,而 write 可以重新创建该路径。如果另一个写入方赢得创建竞态,本次重试会返回 `FS_NOT_OBSERVED`,并保留获胜方写入的文件。 + +观测载荷是由包拥有的事件约定变更,因此所有生产方、监听器、不变式、生成的 Cordis 目录、子系统文档以及两套文件系统工具都必须同步更新。策略保留[事件门禁决策](../architecture/2026-06-26-file-context-as-event-gate.md)确立的 read 一次 `stat`、write/edit 零次 `stat` 预算、所有者隔离、dispose 行为和可选部署边界。 + +组装后的文件系统快照固定面向模型的恢复链;提供方测试则在暂存后注入一个创建者,以证明发布不会覆盖竞争目标。[带防护变更恢复指令决策](../feature/2026-08-03-fs-tool-error-remedy.md)仍然拥有面向模型的恢复措辞;本 Agent Note 使其中的删除路径能够生效。 diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml index d9d9142437..484b6bfe36 100644 --- a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.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/feature/2026-08-03-fs-tool-error-remedy.md -2026-08-03-fs-tool-error-remedy.md: f227c31365725652b130e097d70c79d3daab3684 -2026-08-03-fs-tool-error-remedy.zh.md: 6bb0d0b6ffcda282db67dd1a54f0074b3be5c6e1 +2026-08-03-fs-tool-error-remedy.md: c4f0bb86100a4856c536edf22e520662e19ea4f6 +2026-08-03-fs-tool-error-remedy.zh.md: 88309bd630732794dfb2401b60ad799a3d63146b diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md index f227c31365..c4f0bb8610 100644 --- a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md @@ -29,4 +29,4 @@ In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as th Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes. -The remedy is not a promise: a deleted observed target cannot be unblocked, because re-reading a missing file fails with `FS_NOT_FOUND` and records no observation. That dead end is pinned fail-closed in the integration tests — the retried mutation fails identically until the target exists again and is freshly observed. +The [filesystem absence-observation follow-up](../bug-fix/2026-08-09-filesystem-absence-observation.md) makes the stale remedy actionable for external deletion. The failed reread still returns `FS_NOT_FOUND`, but records confirmed absence: edit then returns `FS_NOT_FOUND` without another stale remedy, while write retries as an atomic `createIfAbsent` and preserves any concurrent creator. diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md index 6bb0d0b6ff..88309bd630 100644 --- a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md @@ -29,4 +29,4 @@ Status: implemented 两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs` 与 `dsh-fs-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装层(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。 -恢复指令不是承诺:已观察但被删除的目标无法被解除阻塞,因为重新读取缺失文件会以 `FS_NOT_FOUND` 失败且不记录观察。这一死胡同在集成测试中以 fail-closed 方式固定——在目标重新存在并被重新观察之前,重试的变更以相同方式失败。 +[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)使外部删除场景下的陈旧恢复指令能够生效。失败的重新读取仍返回 `FS_NOT_FOUND`,但会记录确认缺失:随后 edit 返回 `FS_NOT_FOUND`,不再附加陈旧恢复指令;write 则以原子 `createIfAbsent` 重试,并保留任何并发创建者写入的文件。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 5ec532504d..9607dc8d1a 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: a059159aae690330c55971bc49d62fcb1b030660 -config-catalog.zh.md: 9a0742ec8af10db90d0a2aad146c0cbec5a1a8a2 +config-catalog.md: 5f3bc744b25bf20d50e88ce75812fe8bd624905a +config-catalog.zh.md: a7d4252c526d36643a1b9f7aebd627faa60e1c77 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a059159aae..5f3bc744b2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2137,7 +2137,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:496`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:497`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9a0742ec8a..a7d4252c52 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2138,7 +2138,7 @@ export interface Config { } ``` -来源:[`packages/fs/tool-str-replace-editor/src/index.ts:496`](../packages/fs/tool-str-replace-editor/src/index.ts) +来源:[`packages/fs/tool-str-replace-editor/src/index.ts:497`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 788069e02d..41da1ee118 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: d5f6674a5e9355f6f2c3fac23a45fcda180b3a0f -event-producer-consumer.zh.md: 7568e852c891e6c9556486899fe1bdcd3053cb07 +event-producer-consumer.md: 5b36725402c0a12c5e2a09c743c2e7cf28d2c14a +event-producer-consumer.zh.md: 2d4c805f9a5d3b0531ee59eebe9e6564347f0a00 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d5f6674a5e..5b36725402 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -24,9 +24,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:64`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:73`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:56`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `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:73`](../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:62`](../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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 7568e852c8..2d4c805f9a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -26,9 +26,9 @@ | `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:64`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:73`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:56`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `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:73`](../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:62`](../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) | diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 3dad767e63..04c08db9a6 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.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/filesystem.md -filesystem.md: 6748ddc7f290b6714302223758587226647641e7 -filesystem.zh.md: 28535d3d9d40e92bb6d5b2b0995a535b42fd9a23 +filesystem.md: 5d06611cbad2a3f146ddef4dbc96b2b6fc0a5b46 +filesystem.zh.md: 97aac2ee20783787909605d90a94181bf093d78a diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index 6748ddc7f2..5d06611cba 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -2,7 +2,7 @@ English | [中文](filesystem.zh.md) -The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional version guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) adds observed-state and freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas. +The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) records observed presence or absence and adds freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas. The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. @@ -113,7 +113,7 @@ interface FsDirEntry { ## Write and edit guards (provider contract) -Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. +Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. ```ts type-equiv /** @@ -181,7 +181,18 @@ interface FsEditOutcome { `dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. -`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener would surface as the tool's `isError` result for a mutation that already succeeded. The generated [cordis surface](#cordis-surface) below shows the exact signatures. +`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event carrying an `FsObservation`: present at a version or confirmed absent. It is dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener can replace a read error or surface as the tool's `isError` result after a mutation already succeeded. The generated [cordis surface](#cordis-surface) below shows the exact signatures. + +```ts type-equiv +/** + * One authoritative observation of a target. A present observation carries the + * version used by guarded replacement; an absent observation authorizes only a + * guarded create, never an edit. + */ +type FsObservation = + | { readonly kind: 'present'; readonly version: FsVersion } + | { readonly kind: 'absent' } +``` ## Execution context (policy plugin) @@ -209,7 +220,7 @@ interface FsPolicyExec { ## Read outcome (consumer / read rendering) -A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. +A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits a present `fs/observed` with the stat's version), so any windowed read can authorize a later write/edit when the file is unchanged. A metadata miss emits an absent observation before the tool returns `FS_NOT_FOUND`, allowing a later guarded write to recreate an externally deleted target without authorizing edit. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ @@ -227,7 +238,7 @@ interface FileReadOutcome { ## Observed-file state (policy plugin) -Observed state is a `WeakMap>` held inside the `dsh-fs-policy` plugin. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no separate `hasRead` flag and no view distinction. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). +Observed state is a `WeakMap>` held inside the `dsh-fs-policy` plugin. Missing map entry means unseen; `{ kind: 'absent' }` means a tool read/view confirmed absence; `{ kind: 'present', version }` means a read, write, or edit observed that version. The write decision maps unseen and absent to `createIfAbsent`, while present maps to `replaceIfVersion`; the edit decision maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. Disposal drops everything (HMR safety), and the policy performs no filesystem I/O. ## Error taxonomy (provider contract) @@ -254,7 +265,7 @@ type FsErrorCode = | 'FS_ABORTED' ``` -`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a write/edit — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one (or an edit hit a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. +`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a write/edit — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. ## No timeouts on file IO @@ -262,7 +273,7 @@ type FsErrorCode = ## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures. +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures. @@ -376,7 +387,7 @@ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise * @param target - the resolved target to write. * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. - * @param signal - aborts before the atomic rename takes effect. + * @param signal - aborts before atomic publication takes effect. * @param sandboxPolicy - the per-call mode and workspace root this write * runs under; a sandboxing backend fences the write by it, the bare backend * ignores it. Omit to leave the backend its own default. @@ -391,7 +402,7 @@ abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, * @param target - the resolved target to edit. * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. - * @param signal - aborts before the atomic rename takes effect. + * @param signal - aborts before atomic publication takes effect. * @param sandboxPolicy - the per-call mode and workspace root this edit runs * under; a sandboxing backend fences the edit by it, the bare backend * ignores it. Omit to leave the backend its own default. @@ -402,7 +413,7 @@ abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: Types: [SandboxExecutionPolicy](sandbox.md) -Source: [`packages/fs/fs/src/index.ts:83`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:86`](../../packages/fs/fs/src/index.ts) @@ -425,27 +436,28 @@ Single-slot decision for the next FileSystem.editText. Calling `next()` yields a 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` -Source: [`packages/fs/fs/src/index.ts:64`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:66`](../../packages/fs/fs/src/index.ts) #### `fs/observed` — emit -Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited. +Record an authoritative positive or negative observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited. ```ts cordis-catalog /** - * Record a successful observation. Listeners must be synchronous recorders: - * throws fail the tool call and returned promises are not awaited. - * @param target - the target that was read/written/edited. - * @param version - the version the actor now holds as its observation. + * Record an authoritative positive or negative observation. Listeners must + * be synchronous recorders: throws fail the tool call and returned promises + * are not awaited. + * @param target - the target whose presence or absence was observed. + * @param observation - present with its version, or confirmed absent. * @param actor - the observing tool-execution context; undefined records nothing useful. * @mode emit */ -'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void +'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void ``` -Source: [`packages/fs/fs/src/index.ts:73`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:76`](../../packages/fs/fs/src/index.ts) @@ -465,5 +477,5 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` -Source: [`packages/fs/fs/src/index.ts:56`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:58`](../../packages/fs/fs/src/index.ts) diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 28535d3d9d..97aac2ee20 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -2,7 +2,7 @@ [English](filesystem.md) | 中文 -可选的文件系统能力由四个部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选版本守卫的原子文本操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-policy](../../packages/fs/fs-policy) 通过事件(而非服务)添加观测状态与新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop(智能体循环)主干之外;替换后端不会改变策略或工具 schema。 +可选的文件系统能力由四个部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选守卫的原子文本操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-policy](../../packages/fs/fs-policy) 记录观测到的存在或缺失状态,并通过事件(而非服务)添加新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop(智能体循环)主干之外;替换后端不会改变策略或工具 schema。 该模型是**加法式而非减法式**的:`ctx.fs` 本身就是一个完整、无约束的文本存储 seam(`write` 无条件创建或覆盖,`edit` 无条件替换字面文本)。`dsh-fs-policy` 是一个插件,通过裁决 `fs/*` waterfall(瀑布式事件)在上层*叠加*策略;移除它只会暴露裸提供方,而不会破坏工具,因为工具与策略之间没有方法级耦合。加载了 `dsh-tool-fs` 的部署通常也应加载 `dsh-fs-policy`,使默认行为为「先读后写/编辑」。 @@ -113,7 +113,7 @@ interface FsDirEntry { ## 写入与编辑守卫(提供方约定) -`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 +`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 ```ts type-equiv /** @@ -181,7 +181,18 @@ interface FsEditOutcome { `dsh-fs` 拥有三个事件,由工具分发、策略插件监听,使发射方(`dsh-tool-fs`)与监听方(`dsh-fs-policy`)共享词汇,而发射方无需依赖策略插件。它们只携带 `dsh-fs` 词汇加一个不透明的 `object` actor,不含面向模型的概念,也不含 agent/会话所有者结构。 -`fs/write-intent` 与 `fs/edit-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该槽按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不会捕获该 emit 抛出的异常——抛出异常的监听方会导致工具为一次已经成功的变更返回 `isError` 结果。下方生成的 [cordis surface](#cordis-surface) 展示确切签名。 +`fs/write-intent` 与 `fs/edit-intent` 是**单槽决策 waterfall**:工具分发时附带一个默认 thunk(返回 `undefined`,即裸提供方),监听方完全决策而不调用 `next()`。该槽按注册顺序先到先得——由策略插件占据是部署约定,而非强制不变式。`fs/observed` 是一个即发即弃的记录事件,携带 `FsObservation`:存在于某个版本,或确认缺失。该事件通过普通 `ctx.emit` 分发;其监听方必须是同步的、仅产生副作用,因为工具不会捕获该 emit 抛出的异常——抛出异常的监听方可能取代读取操作原本待返回的错误,或使工具在变更已经成功后返回 `isError` 结果。下方生成的 [cordis surface](#cordis-surface) 展示确切签名。 + +```ts type-equiv +/** + * One authoritative observation of a target. A present observation carries the + * version used by guarded replacement; an absent observation authorizes only a + * guarded create, never an edit. + */ +type FsObservation = + | { readonly kind: 'present'; readonly version: FsVersion } + | { readonly kind: 'absent' } +``` ## 执行上下文(策略插件) @@ -209,7 +220,7 @@ interface FsPolicyExec { ## 读取结果(消费方 / 读取渲染) -文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具直接用 stat 的版本 emit `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。 +文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具以 stat 的版本 emit 表示存在的 `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前 emit 缺失观测,使后续带防护的写入可以重新创建外部删除的目标,但不会授权 edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。 ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ @@ -227,7 +238,7 @@ interface FileReadOutcome { ## 已观测文件状态(策略插件) -已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap>`。**当且仅当**所有者已读取、写入或编辑过该目标时(每次成功都 emit `fs/observed`),条目才存在,因此其存在本身就是先前观测的记录——没有单独的 `hasRead` 标志,也没有视图区分。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。成功的 read/write/edit 会刷新该所有者对应的已记录版本;dispose(资源释放)时丢弃全部数据(HMR(热模块替换)安全)。 +已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap>`。映射中没有条目表示未见;`{ kind: 'absent' }` 表示工具的 read/view 已确认缺失;`{ kind: 'present', version }` 表示 read、write 或 edit 观测到该版本。写入决策把未见和缺失映射到 `createIfAbsent`,把存在映射到 `replaceIfVersion`;编辑决策把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。dispose(资源释放)时丢弃全部数据(HMR(热模块替换)安全),策略不执行任何文件系统 I/O。 ## 错误分类体系(提供方约定) @@ -254,7 +265,7 @@ type FsErrorCode = | 'FS_ABORTED' ``` -目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了写入/编辑——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观察记录(或 `createIfAbsent` 遇到了现有文件)。`FS_STALE_VERSION` 表示后端版本不再与观察到的版本匹配(或编辑操作遇到缺失目标)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。 +目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了写入/编辑——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观测记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观测到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。 ## 文件 IO 不设超时 @@ -262,7 +273,7 @@ type FsErrorCode = ## 服务与插件 -`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:对写入/编辑意图 waterfall 作出决策(提供 `createIfAbsent`/`replaceIfVersion`/`{ version }`,或抛出 `FS_NOT_OBSERVED`),并在 `fs/observed` 上记录。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 +`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 @@ -376,7 +387,7 @@ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise * @param target - the resolved target to write. * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. - * @param signal - aborts before the atomic rename takes effect. + * @param signal - aborts before atomic publication takes effect. * @param sandboxPolicy - the per-call mode and workspace root this write * runs under; a sandboxing backend fences the write by it, the bare backend * ignores it. Omit to leave the backend its own default. @@ -391,7 +402,7 @@ abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, * @param target - the resolved target to edit. * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. - * @param signal - aborts before the atomic rename takes effect. + * @param signal - aborts before atomic publication takes effect. * @param sandboxPolicy - the per-call mode and workspace root this edit runs * under; a sandboxing backend fences the edit by it, the bare backend * ignores it. Omit to leave the backend its own default. @@ -402,7 +413,7 @@ abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: Types: [SandboxExecutionPolicy](sandbox.md) -Source: [`packages/fs/fs/src/index.ts:83`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:86`](../../packages/fs/fs/src/index.ts) @@ -425,27 +436,28 @@ Single-slot decision for the next FileSystem.editText. Calling `next()` yields a 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` -Source: [`packages/fs/fs/src/index.ts:64`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:66`](../../packages/fs/fs/src/index.ts) #### `fs/observed` — emit -Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited. +Record an authoritative positive or negative observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited. ```ts cordis-catalog /** - * Record a successful observation. Listeners must be synchronous recorders: - * throws fail the tool call and returned promises are not awaited. - * @param target - the target that was read/written/edited. - * @param version - the version the actor now holds as its observation. + * Record an authoritative positive or negative observation. Listeners must + * be synchronous recorders: throws fail the tool call and returned promises + * are not awaited. + * @param target - the target whose presence or absence was observed. + * @param observation - present with its version, or confirmed absent. * @param actor - the observing tool-execution context; undefined records nothing useful. * @mode emit */ -'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void +'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void ``` -Source: [`packages/fs/fs/src/index.ts:73`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:76`](../../packages/fs/fs/src/index.ts) @@ -465,5 +477,5 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` -Source: [`packages/fs/fs/src/index.ts:56`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:58`](../../packages/fs/fs/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index cb88c51de0..752a8f6a9e 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-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/tool-catalog.md -tool-catalog.md: 36ea230990abd65ad0435e257e237f9bf873108f -tool-catalog.zh.md: f6073b84a08ed25c3fa6e8104388e00aa774a34a +tool-catalog.md: abcba1ca5dcb148aa0ee5578ff4fd0373993db1d +tool-catalog.zh.md: 32c761d0d2854bf2310eb0ddb382553575bd4df9 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 36ea230990..abcba1ca5d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,8 +22,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | -| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | -| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | +| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | +| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful mutation`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index f6073b84a0..32c761d0d2 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,8 +24,8 @@ | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.bash`、`ctx.systemPrompt`、`ctx.bashEnv`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.bash` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.tasks` 运行时,并通过 `task_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-bash-env`。每次调用都在新进程中运行,不使用持久 PTY 会话;ConPTY 尚在规划中。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`、`cordis_mount`、`cordis_unmount` | `ctx.tools` | `tool/call`、`tool/result`、`process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.pty`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | -| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after successful file operations`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | -| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after successful file operations`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件,上述工具 schema 都完全相同。 | +| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | +| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful mutation`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件,上述工具 schema 都完全相同。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.tools`、`ctx.pty`、`ctx.systemPrompt`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.tasks`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 | | `@deepseek-ai/dsh-tool-goal` | `create_goal`、`get_goal`、`update_goal` | `ctx.tools`、`ctx.agents`、`ctx.goals`、`ctx.systemPrompt`、`a calling Agent in an authorized open turn` | `tool/call`、`goal/change for mutations`、`tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限;complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 | diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b0b646eade..af044fc58f 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -255,6 +255,7 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true }, { name: 'fs-read-window', hasModelTurn: true, recorded: true }, { name: 'fs-policy-reject', hasModelTurn: true, recorded: true }, + { name: 'fs-delete-recreate', hasModelTurn: true, recorded: true }, { name: 'multi-turn', hasModelTurn: true, recorded: true }, { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true }, // Keyless, authored (like error-finish): a live provider cannot be coaxed diff --git a/examples/acp-agent/tests/snapshots/fs-delete-recreate/input.json b/examples/acp-agent/tests/snapshots/fs-delete-recreate/input.json new file mode 100644 index 0000000000..f6c181074f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-delete-recreate/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Perform these exact steps in order on deleted.txt in the current directory: (1) use the read tool to read it, (2) use the bash tool with command `rm deleted.txt`, (3) use the read tool on deleted.txt again and observe the not-found error, (4) use the write tool to recreate deleted.txt with exactly the content `fresh\\n`, and (5) reply with exactly the single word DONE. Do not use any other tools or skip any step." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-delete-recreate/session.jsonl b/examples/acp-agent/tests/snapshots/fs-delete-recreate/session.jsonl new file mode 100644 index 0000000000..7cccd53d6b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-delete-recreate/session.jsonl @@ -0,0 +1,62 @@ +{"type":"session","version":0,"id":"b8c89c36-55db-48cf-9f3e-76140cd37aff","createdAt":1786259114417,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786259114424,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Perform these exact steps in order on deleted.txt in the current directory: (1) use the read tool to read it, (2) use the bash tool with command `rm deleted.txt`, (3) use the read tool on deleted.txt again and observe the not-found error, (4) use the write tool to recreate deleted.txt with exactly the content `fresh\\n`, and (5) reply with exactly the single word DONE. Do not use any other tools or skip any step."}],"source":{"kind":"user"},"role":"user","id":"d2c7929c-e9af-4011-85c4-fe35eb4d5bfe"}]}} +{"type":"turn/start","seq":1,"time":1786259114425,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786259114425,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786259114479,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786259114479,"data":{"content":[{"type":"text","text":"Perform these exact steps in order on deleted.txt in the current directory: (1) use the read tool to read it, (2) use the bash tool with command `rm deleted.txt`, (3) use the read tool on deleted.txt again and observe the not-found error, (4) use the write tool to recreate deleted.txt with exactly the content `fresh\\n`, and (5) reply with exactly the single word DONE. Do not use any other tools or skip any step."}],"source":{"kind":"user"},"role":"user","id":"d2c7929c-e9af-4011-85c4-fe35eb4d5bfe"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786259114479,"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":"09f49ebb-fe8b-4100-9fb1-63461c4e5ff4"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786259114480,"data":{"title":"Perform these exact steps in","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786259114481,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786259114481,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786259115592,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1786259115592,"data":{"turn":1,"step":1,"index":0,"dt":[100,24,1,0,0,0,21,0,0,0,0,1,22,0,1,23,1,0,0,25,0,21,1,0,0,0,21,1,0,0,24,0,1,0,0,0,20,0,27,1,0,0,26,1,0,21,1,0,0,19,89,0,0,0,0,1,0,0,0,0,0,1,0],"texts":["The"," user"," wants"," me"," to"," perform"," exact"," steps"," in"," order",":\n","1","."," read"," deleted",".txt","\n","2","."," bash"," rm"," deleted",".txt","\n","3","."," read"," deleted",".txt"," again"," and"," observe"," not","-found"," error","\n","4","."," write"," deleted",".txt"," with"," content"," \"","fresh","\\n","\"\n","5","."," reply"," with"," exactly"," \"","D","ONE","\"\n\n","Let"," me"," do"," step"," ","1"," first","."]}} +{"type":"assistant/chunk","seq":74,"time":1786259116112,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":75,"time0":1786259116112,"data":{"turn":1,"step":1,"index":1,"dt":[22,1,0,0,0,0,25,0,0,0,21,0],"id":"call_00_vDjxu2gdu87Gj8XZZvba8767","name":"read","args":["","{","\"","file","_path","\"",": ","\"","de","leted",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":88,"time":1786259116233,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to perform exact steps in order:\n1. read deleted.txt\n2. bash rm deleted.txt\n3. read deleted.txt again and observe not-found error\n4. write deleted.txt with content \"fresh\\n\"\n5. reply with exactly \"DONE\"\n\nLet me do step 1 first."}}}} +{"type":"assistant/chunk","seq":89,"time":1786259116233,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_vDjxu2gdu87Gj8XZZvba8767","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}}}} +{"type":"assistant/chunk","seq":90,"time":1786259116233,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":6102,"outputTokens":110,"cacheReadTokens":0,"reasoningTokens":64}}}} +{"type":"assistant/chunk","seq":91,"time":1786259116233,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":92,"time":1786259116237,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to perform exact steps in order:\n1. read deleted.txt\n2. bash rm deleted.txt\n3. read deleted.txt again and observe not-found error\n4. write deleted.txt with content \"fresh\\n\"\n5. reply with exactly \"DONE\"\n\nLet me do step 1 first."},{"type":"tool-call","id":"call_00_vDjxu2gdu87Gj8XZZvba8767","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"121e3a9b-14bd-491b-817d-5abb0be3f45d"},"usage":{"inputTokens":6102,"outputTokens":110,"cacheReadTokens":0,"reasoningTokens":64}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91],"surfaceOp":"append"} +{"type":"tool/call","seq":93,"time":1786259116238,"data":{"turn":1,"step":1,"callId":"call_00_vDjxu2gdu87Gj8XZZvba8767","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}} +{"type":"tool/result","seq":94,"time":1786259116249,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_vDjxu2gdu87Gj8XZZvba8767"},"content":[{"type":"tool-result","toolCallId":"call_00_vDjxu2gdu87Gj8XZZvba8767","content":[{"type":"text","text":"{{cwd}}/deleted.txt\nfile\n\n1: original\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c3c0fe4a-3c93-433b-990e-1e5963964505"},"meta":{"path":"{{cwd}}/deleted.txt","offset":1,"lines":[{"number":1,"text":"original"}],"totalLines":1}},"sourceEventSeqs":[93],"surfaceOp":"append"} +{"type":"step/end","seq":95,"time":1786259116249,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":96,"time":1786259116260,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":97,"time":1786259117343,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":98,"time0":1786259117344,"data":{"turn":1,"step":2,"index":0,"dt":[23,1,0,0,1,21,1,0,0,0,47,0,1,1,0,21,1,0,0,1,18,1],"id":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","name":"bash","args":["","{","\"","command","\"",": ","\"","rm"," deleted",".txt","\"",", ","\"","description","\"",": ","\"","Delete"," deleted",".txt"," file","\"","}"]}} +{"type":"assistant/chunk","seq":121,"time":1786259117528,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","name":"bash","arguments":"{\"command\": \"rm deleted.txt\", \"description\": \"Delete deleted.txt file\"}"}}}} +{"type":"assistant/chunk","seq":122,"time":1786259117528,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":64,"cacheReadTokens":6144,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":123,"time":1786259117528,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":124,"time":1786259117529,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","name":"bash","arguments":"{\"command\": \"rm deleted.txt\", \"description\": \"Delete deleted.txt file\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d85f99a5-732d-4127-85d7-cbd7bfa73bb2"},"usage":{"inputTokens":151,"outputTokens":64,"cacheReadTokens":6144,"reasoningTokens":0}},"sourceEventSeqs":[97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123],"surfaceOp":"append"} +{"type":"tool/call","seq":125,"time":1786259117529,"data":{"turn":1,"step":2,"callId":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","name":"bash","arguments":"{\"command\": \"rm deleted.txt\", \"description\": \"Delete deleted.txt file\"}"}} +{"type":"tool/result","seq":126,"time":1786259117553,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"7a80dfb4-3234-41c5-b276-3384f4198765"}},"sourceEventSeqs":[125],"surfaceOp":"append"} +{"type":"step/end","seq":127,"time":1786259117553,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":128,"time":1786259117560,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":129,"time":1786259118917,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":130,"time0":1786259118917,"data":{"turn":1,"step":3,"index":0,"dt":[25,2,0,0,0,1,21,1,0,0,27,2],"id":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","name":"read","args":["","{","\"","file","_path","\"",": ","\"","de","leted",".txt","\"","}"]}} +{"type":"assistant/chunk","seq":143,"time":1786259119037,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}}}} +{"type":"assistant/chunk","seq":144,"time":1786259119037,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":101,"outputTokens":46,"cacheReadTokens":6272,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":145,"time":1786259119037,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":146,"time":1786259119038,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c08b2b60-786f-4a74-a826-e2e3aa09e851"},"usage":{"inputTokens":101,"outputTokens":46,"cacheReadTokens":6272,"reasoningTokens":0}},"sourceEventSeqs":[129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145],"surfaceOp":"append"} +{"type":"tool/call","seq":147,"time":1786259119039,"data":{"turn":1,"step":3,"callId":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","name":"read","arguments":"{\"file_path\": \"deleted.txt\"}"}} +{"type":"tool/result","seq":148,"time":1786259119049,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/deleted.txt\": not found"}],"isError":true}],"role":"user","id":"660e8735-5bf4-44f8-8835-7aeb70d6a95d"},"error":{"name":"FsError","code":"FS_NOT_FOUND"}},"sourceEventSeqs":[147],"surfaceOp":"append"} +{"type":"step/end","seq":149,"time":1786259119050,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":150,"time":1786259119058,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":151,"time":1786259120173,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":152,"time0":1786259120174,"data":{"turn":1,"step":4,"index":0,"dt":[108,1,1,0,0,0,1,0,1,0,0,0,1,0,0,0,1,72,0,0,1],"id":"call_00_ET_OqjRZggKy6eetff5jh3V9977","name":"write","args":["","{","\"","file","_path","\"",": ","\"","de","leted",".txt","\"",", ","\"","content","\"",": ","\"","fresh","\\n","\"","}"]}} +{"type":"assistant/chunk","seq":174,"time":1786259120361,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_OqjRZggKy6eetff5jh3V9977","name":"write","arguments":"{\"file_path\": \"deleted.txt\", \"content\": \"fresh\\n\"}"}}}} +{"type":"assistant/chunk","seq":175,"time":1786259120361,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":79,"outputTokens":63,"cacheReadTokens":6400,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":176,"time":1786259120361,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":177,"time":1786259120362,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_OqjRZggKy6eetff5jh3V9977","name":"write","arguments":"{\"file_path\": \"deleted.txt\", \"content\": \"fresh\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de52e93e-d45e-488b-b110-ce10de3387ba"},"usage":{"inputTokens":79,"outputTokens":63,"cacheReadTokens":6400,"reasoningTokens":0}},"sourceEventSeqs":[151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176],"surfaceOp":"append"} +{"type":"tool/call","seq":178,"time":1786259120363,"data":{"turn":1,"step":4,"callId":"call_00_ET_OqjRZggKy6eetff5jh3V9977","name":"write","arguments":"{\"file_path\": \"deleted.txt\", \"content\": \"fresh\\n\"}"}} +{"type":"tool/result","seq":179,"time":1786259120381,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_00_ET_OqjRZggKy6eetff5jh3V9977"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_OqjRZggKy6eetff5jh3V9977","content":[{"type":"text","text":"{{cwd}}/deleted.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"f10e6310-8f96-468b-9837-3068dc8af472"},"meta":{"diffs":[]}},"sourceEventSeqs":[178],"surfaceOp":"append"} +{"type":"step/end","seq":180,"time":1786259120381,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":181,"time":1786259120389,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":182,"time":1786259121079,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":183,"time":1786259121079,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":184,"time":1786259121104,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":185,"time":1786259121115,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":186,"time":1786259121116,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":88,"outputTokens":3,"cacheReadTokens":6528,"reasoningTokens":0}}}} +{"type":"assistant/chunk","seq":187,"time":1786259121116,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":188,"time":1786259121116,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6c904bc2-55c6-4dfe-85a6-0a5d7d2ad7b4"},"usage":{"inputTokens":88,"outputTokens":3,"cacheReadTokens":6528,"reasoningTokens":0}},"sourceEventSeqs":[182,183,184,185,186,187],"surfaceOp":"append"} +{"type":"step/end","seq":189,"time":1786259121117,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":190,"time":1786259121117,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-delete-recreate/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-delete-recreate/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-delete-recreate/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":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-delete-recreate/workspace/deleted.txt b/examples/acp-agent/tests/snapshots/fs-delete-recreate/workspace/deleted.txt new file mode 100644 index 0000000000..4b48deed3a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-delete-recreate/workspace/deleted.txt @@ -0,0 +1 @@ +original diff --git a/packages/e2b/fs-e2b/README.i18n.yaml b/packages/e2b/fs-e2b/README.i18n.yaml index 90c7931644..4e29843fad 100644 --- a/packages/e2b/fs-e2b/README.i18n.yaml +++ b/packages/e2b/fs-e2b/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/e2b/fs-e2b/README.md -README.md: b346b4c5bc7bc2888ccd3dc210bb90e0f1cc5965 -README.zh.md: b84b2382c03dc6e422228b19a58aff927e8a5db7 +README.md: 039ad72a9651a1e8907c2c63b8c83d113aedbb4e +README.zh.md: be97bbd92edb528f9560518398a0cb70f36b992b diff --git a/packages/e2b/fs-e2b/README.md b/packages/e2b/fs-e2b/README.md index b346b4c5bc..039ad72a96 100644 --- a/packages/e2b/fs-e2b/README.md +++ b/packages/e2b/fs-e2b/README.md @@ -9,8 +9,8 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide - **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute. - **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules. - **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing. -- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, preserve an existing file's POSIX mode, and publish the staged file through E2B's same-filesystem atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the filesystem seam's observed-state semantics. -- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before rename. The signal is not forwarded into the rename RPC, so cancellation cannot interrupt the atomic commit; a successful rename is the commit point. +- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln` instead, making the commit atomically no-replace; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. +- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before publication. The signal is not forwarded into the rename or guarded-link commit, so cancellation cannot interrupt atomic publication or turn a committed write into a reported failure. The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only. @@ -25,7 +25,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **No host synchronization** — an empty E2B cwd stays empty until a tool, command, or external process populates it; local files are neither uploaded nor reflected back. -- **Mutation coordination is host-process-local** — another harness connection or remote command can race the adapter; version guards detect only metadata changes represented by E2B. +- **Mutation coordination is host-process-local** — `createIfAbsent` preserves a remote creator racing publication, but another harness connection or command can still race replacement; version guards detect only metadata changes represented by E2B. - **Reads reopen canonical targets by path** — a concurrent remote path replacement between resolution and stream opening is not fenced by a stable file handle; no observed product defect justifies a provider-specific bounded-read protocol in this POC. - **Whole-file mutation costs remain** — overwrite diffs and literal edits read complete files into host memory, and every operation incurs E2B controller latency. - **The POC targets E2B's default Linux image** — it relies on GNU `realpath`/`base64`/`chmod`, same-filesystem rename, streaming reads, and metadata extended attributes; custom templates are outside this POC. diff --git a/packages/e2b/fs-e2b/README.zh.md b/packages/e2b/fs-e2b/README.zh.md index b84b2382c0..be97bbd92e 100644 --- a/packages/e2b/fs-e2b/README.zh.md +++ b/packages/e2b/fs-e2b/README.zh.md @@ -9,8 +9,8 @@ - **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;GNU `realpath -mz` 提供规范化目标身份,且不要求最终文件存在;ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam;目录列表会复用已返回的元数据,并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。 - **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。 - **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。 -- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,保留现有文件的 POSIX mode,并通过 E2B 的同一文件系统原子重命名发布暂存文件。重命名响应会提供已提交的版本,因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留文件系统 seam 的已观察状态语义。 -- **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在 rename 前立即检查。信号不会传入 rename RPC,因此取消无法中断原子提交;成功 rename 是提交点。 +- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln` 发布,使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。 +- **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在发布前立即检查。信号不会传入 rename 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败。 该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。 @@ -25,7 +25,7 @@ ## 已知限制与延后工作 - **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令或外部进程填充它;本地文件既不会上传,也不会同步回本地。 -- **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。 +- **变更协调仅限宿主进程内**:`createIfAbsent` 会保留与发布发生竞态的远程创建者所写入的文件,但另一个 harness 连接或命令仍可能与替换操作发生竞态;版本防护只能检测 E2B 元数据所体现的变更。 - **读取会按路径重新打开规范化目标**:在解析与打开流之间若并发替换远程路径,该操作没有稳定文件句柄提供围栏;在该 POC 中,没有已观察到的产品缺陷能够证明提供方专用的有界读取协议值得引入。 - **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。 - **该 POC 面向 E2B 默认 Linux 镜像**:它依赖 GNU `realpath`/`base64`/`chmod`、同一文件系统内的 rename、流式读取和元数据扩展属性;自定义模板不在该 POC 范围内。 diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index 83ff7bd99b..04b23a6775 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -332,7 +332,13 @@ export class E2BFileSystem extends FileSystem { } this.checkWriteIntent(existing, expected, target) const before = existing === undefined ? null : await this.readForDiff(target, signal) - const version = await this.writeAtomic(target, content, existing, signal) + const version = await this.writeAtomic( + target, + content, + existing, + expected?.kind === 'createIfAbsent', + signal, + ) return { operation: existing === undefined ? 'create' : 'update', version, @@ -363,7 +369,7 @@ export class E2BFileSystem extends FileSystem { const before = normalizeLineEndings(raw) const after = literalEdit(before, edit, target.displayPath) const storage = restoreLineEndings(after, detectsCrlf(raw)) - const version = await this.writeAtomic(target, storage, existing, signal) + const version = await this.writeAtomic(target, storage, existing, false, signal) return { version, before, after } }) } @@ -450,6 +456,7 @@ export class E2BFileSystem extends FileSystem { target: FsTarget, content: string, existing: EntryInfo | undefined, + createIfAbsent: boolean, signal?: AbortSignal, ): Promise> { assertNotAborted(signal, 'write') @@ -476,7 +483,28 @@ export class E2BFileSystem extends FileSystem { commandOpts(signal), ) assertNotAborted(signal, 'write') - const committed = await sandbox.files.rename(temporary, targetPath) + let committed: EntryInfo + if (createIfAbsent) { + const staged = await sandbox.files.getInfo(temporary, signalOpts(signal)) + assertNotAborted(signal, 'write') + const targetArg = quoteE2BShellArg(targetPath) + const publication = await sandbox.commands.run( + `if ln -- ${quoteE2BShellArg(temporary)} ${targetArg}; then printf created; elif test -e ${targetArg} || test -L ${targetArg}; then printf exists; else exit 1; fi`, + commandOpts(undefined), + ) + if (publication.stdout === 'exists') { + throw new FsError( + `cannot overwrite existing "${target.displayPath}" without reading it first`, + 'FS_NOT_OBSERVED', + ) + } + if (publication.stdout !== 'created') { + throw new Error('guarded create returned an invalid publication result') + } + committed = { ...staged, name: posix.basename(targetPath), path: targetPath } + } else { + committed = await sandbox.files.rename(temporary, targetPath) + } try { await sandbox.files.remove(stagingDirectory) } catch (_committedStagingCleanupFailure) { diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 9cad00b6eb..973cf7c5c8 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -37,6 +37,7 @@ class FakeRemote { readonly writes: Array<{ path: string; data: string; metadata?: Record }> = [] readonly writeParentModes: number[] = [] readonly renames: Array<{ from: string; to: string }> = [] + readonly links: Array<{ from: string; to: string }> = [] readonly removals: string[] = [] readonly commands: string[] = [] streamChunks: Uint8Array[] | undefined @@ -51,6 +52,8 @@ class FakeRemote { nextRemoveError: unknown canonicalOutput: string | undefined abortAfterRename: AbortController | undefined + competitorBeforeLink: { path: string; data: string } | undefined + guardedLinkOutput: string | undefined disappearOnInfo = new Set() private clock = 1 @@ -253,6 +256,28 @@ class FakeRemote { } const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command) if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8) + const guardedLink = new RegExp( + "^if ln -- '([^']+)' '([^']+)'; then printf created; " + + "elif test -e '[^']+' \\|\\| test -L '[^']+'; then printf exists; else exit 1; fi$", + ).exec(command) + if (guardedLink !== null) { + const from = guardedLink[1]! + const to = guardedLink[2]! + if (this.guardedLinkOutput !== undefined) { + const stdout = this.guardedLinkOutput + this.guardedLinkOutput = undefined + return { exitCode: 0, stdout, stderr: '' } + } + if (this.competitorBeforeLink?.path === to) { + this.file(to, this.competitorBeforeLink.data) + this.competitorBeforeLink = undefined + } + if (this.nodes.has(to)) return { exitCode: 0, stdout: 'exists', stderr: '' } + this.nodes.set(to, this.required(from)) + this.links.push({ from, to }) + this.abortAfterRename?.abort('after commit') + return { exitCode: 0, stdout: 'created', stderr: '' } + } const move = /^mv -f -- '([^']+)' '([^']+)'$/.exec(command) if (move !== null) { if (this.nextRenameError !== undefined) { @@ -475,6 +500,7 @@ describe('E2BFileSystem atomic writes and edits', () => { expect(remote.nodes.get('/workspace/new.txt')?.mode).toBe(0o600) expect(remote.nodes.get('/workspace/new.txt')?.metadata?.['dsh-version']).toBeDefined() expect(remote.writeParentModes).toEqual([0o700]) + expect(remote.links).toHaveLength(1) const stagingDirectory = posix.dirname(remote.writes[0]!.path) expect(posix.dirname(stagingDirectory)).toBe('/workspace') expect(remote.removals).toContain(stagingDirectory) @@ -527,6 +553,33 @@ describe('E2BFileSystem atomic writes and edits', () => { await expectCode(fs.writeText(await fs.resolve('dir'), 'x'), 'FS_NOT_REGULAR_FILE') }) + it('preserves a competitor created after the guarded-create probe', async () => { + const remote = new FakeRemote() + remote.competitorBeforeLink = { path: '/workspace/race.txt', data: 'competitor' } + const { fs } = await setup(remote) + + await expectCode( + fs.writeText(await fs.resolve('race.txt'), 'ours', { kind: 'createIfAbsent' }), + 'FS_NOT_OBSERVED', + ) + expect(new TextDecoder().decode(remote.nodes.get('/workspace/race.txt')?.data)).toBe('competitor') + expect(remote.links).toHaveLength(0) + expect(remote.removals).toHaveLength(1) + }) + + it('rejects an invalid guarded-create publication response before claiming success', async () => { + const remote = new FakeRemote() + remote.guardedLinkOutput = 'unexpected' + const { fs } = await setup(remote) + + await expectCode( + fs.writeText(await fs.resolve('invalid.txt'), 'ours', { kind: 'createIfAbsent' }), + 'FS_IO_ERROR', + ) + expect(remote.nodes.has('/workspace/invalid.txt')).toBe(false) + expect(remote.removals).toHaveLength(1) + }) + it('does not turn an abort observed after a successful move into a failed write', async () => { const remote = new FakeRemote() const controller = new AbortController() @@ -537,6 +590,20 @@ describe('E2BFileSystem atomic writes and edits', () => { expect(controller.signal.aborted).toBe(true) }) + it('does not turn an abort observed after a guarded create into a failed write', async () => { + const remote = new FakeRemote() + const controller = new AbortController() + remote.abortAfterRename = controller + const { fs } = await setup(remote) + await expect(fs.writeText( + await fs.resolve('committed-create'), + 'yes', + { kind: 'createIfAbsent' }, + controller.signal, + )).resolves.toMatchObject({ operation: 'create' }) + expect(controller.signal.aborted).toBe(true) + }) + it('does not turn post-commit staging cleanup failure into a failed write', async () => { const remote = new FakeRemote() remote.nextRemoveError = new Error('empty staging cleanup failed') diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index dd384cd2b1..6aaf691eef 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/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/fs/fs-local/README.md -README.md: ec0cffea1c82ab3bb2485fcb22ac5339d5d2a0d9 -README.zh.md: 99adba8c8b58f1f0b6f0f5ab8b672eb074b38f31 +README.md: ac2f271ad9651797e1aceeba49e7a52533405f10 +README.zh.md: 633d55fe06b2261d9d5b52184987811c87c8f155 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index ec0cffea1c..ac2f271ad9 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. -- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. @@ -39,4 +39,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. - **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits. -- **The per-target mutation lock is in-process only** — a writer in another process is caught only by the optional version guard, never serialized. +- **The per-target mutation lock is in-process only** — guarded create still uses an atomic no-replace publication across processes, but replacement writers in another process are caught only when the optional version guard observes their metadata change; they are never serialized. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 99adba8c8b..633d55fe06 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。 - **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。 -- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内。完成写入和 fsync 后,以 rename 覆盖目标。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 创建缺失目标并拒绝现有目标(`FS_NOT_OBSERVED`);`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 +- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 - **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。 包根 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。 @@ -39,4 +39,4 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 和纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。 - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 - **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。 -- **每目标变更锁仅限进程内**:其他进程中的写入方只会被可选版本防护发现,绝不会被串行化。 +- **每目标变更锁仅限进程内**:即使跨进程,带防护的创建仍采用原子且不替换的发布方式;但只有当可选版本防护观察到元数据变化时,系统才能发现其他进程中的替换写入方,且绝不会将其串行化。 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 7e063ca347..071ad31637 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -1,13 +1,13 @@ /** * Cordis-free local filesystem mechanics. This provider layer returns validated UTF-8 text, * streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes - * stage an exclusive owner-only file in a private sibling directory and atomically rename it. + * stage an exclusive owner-only file in a private sibling directory and atomically publish it. * @module @deepseek-ai/dsh-fs-local/fsio */ import { randomUUID } from 'node:crypto' import { createReadStream } from 'node:fs' -import { chmod, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises' +import { chmod, link, lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises' import type { BigIntStats, Dirent, Stats } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import { TextDecoder } from 'node:util' @@ -20,6 +20,10 @@ function isENOENT(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } +function isEEXIST(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EEXIST' +} + /** * A path component that is expected to be a directory is a regular file (e.g. * resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target @@ -85,7 +89,9 @@ export interface FsIoInternals { copyFileDacl?: (source: string, destination: string) => Promise /** Override the Win32 security-preserving replacement boundary. */ replaceFile?: (replaced: string, replacement: string) => Promise - /** Test hook after the temp file is written/synced but before final chmod+rename. */ + /** Override the hard-link no-replace publication boundary. */ + linkFile?: (existingPath: string, newPath: string) => Promise + /** Test hook after the temp file is written/synced but before final chmod+publication. */ inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise } @@ -426,8 +432,10 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow * @param content - the full UTF-8 text to write. * @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file; * inert as a mode on Windows but identifies replacement security semantics. - * @param signal - cancellation checked before the final rename. + * @param signal - cancellation checked before final publication. * @param internals - Test hook for pinning temp names and observing the staged file. + * @param createIfAbsent - publish with a hard-link no-replace primitive; a + * concurrent creator is preserved and rejected with `FS_NOT_OBSERVED`. */ export async function writeFileAtomic( absolutePath: string, @@ -435,6 +443,7 @@ export async function writeFileAtomic( mode: number | undefined, signal: AbortSignal | undefined, internals: FsIoInternals = {}, + createIfAbsent = false, ): Promise { throwIfAborted(signal, 'write') const directory = dirname(absolutePath) @@ -448,6 +457,7 @@ export async function writeFileAtomic( const platform = internals.platform ?? process.platform const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32 const replaceFile = internals.replaceFile ?? replaceFileWin32 + const linkFile = internals.linkFile ?? link let handle: Awaited> | undefined let stagingCreated = false try { @@ -468,7 +478,18 @@ export async function writeFileAtomic( handle = undefined throwIfAborted(signal, 'write') - if (platform === 'win32' && mode !== undefined) { + if (createIfAbsent) { + try { + await linkFile(tempPath, absolutePath) + } catch (error: unknown) { + if (!isEEXIST(error)) throw error + throw new FsError( + `cannot overwrite existing "${absolutePath}" without reading it first`, + 'FS_NOT_OBSERVED', + { cause: error }, + ) + } + } else if (platform === 'win32' && mode !== undefined) { try { await replaceFile(absolutePath, tempPath) } catch (error: unknown) { diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 6f9af5cec2..6be6a4402b 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -167,7 +167,14 @@ export class LocalFileSystem extends FileSystem { // Preserve prior text for contextual diffs; null falls back to a whole-file diff. // TODO(overwrite-diff-bound): cap this UI-only pre-read for large files. const before = existing ? await readTextForDiff(target.targetKey, signal) : null - await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) + await writeFileAtomic( + target.targetKey, + content, + existing?.mode, + signal, + this.internals, + expected?.kind === 'createIfAbsent', + ) const after = await probe(target.targetKey) return { operation: existing ? 'update' : 'create', diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index ee3abeebb8..531be25360 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -298,6 +298,16 @@ describe('writeText', () => { expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old') }) + it('createIfAbsent preserves a competitor created after the initial probe', async () => { + const path = join(dir, 'a.txt') + const target = await fs.resolve('a.txt') + fs.internals.inspectTemp = async () => { await writeFile(path, 'competitor') } + + await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await readFile(path, 'utf8')).toBe('competitor') + }) + it('replaceIfVersion replaces when the version matches', async () => { await writeFile(join(dir, 'a.txt'), 'old') const target = await fs.resolve('a.txt') diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index ed30b896bf..9b62caba3f 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -496,6 +496,17 @@ describe('writeFileAtomic — temp-file safety', () => { expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) }) + it('surfaces a non-collision guarded-create publication failure and cleans staging', async () => { + const file = join(dir, 'a.txt') + const denied = Object.assign(new Error('link denied'), { code: 'EACCES' }) + + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + linkFile: async () => { throw denied }, + }, true)).rejects.toBe(denied) + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) + }) + it.skipIf(!posixModes)('creates new files owner-only by default', async () => { const file = join(dir, 'a.txt') await writeFileAtomic(file, 'hello', undefined, undefined) diff --git a/packages/fs/fs-policy/README.i18n.yaml b/packages/fs/fs-policy/README.i18n.yaml index c2b72c65c8..e316171061 100644 --- a/packages/fs/fs-policy/README.i18n.yaml +++ b/packages/fs/fs-policy/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/fs/fs-policy/README.md -README.md: bf6486f6f7fe574b576a71b430be91031f7805d2 -README.zh.md: 2e38d63d8e310c7f22b764d46cbec0c82ffb96cf +README.md: 0166690c66f38efe817d8b5779db9a677d22d321 +README.zh.md: 26e0199e6deb7fc12a860ab01884b70c1b7c9b38 diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index bf6486f6f7..0166690c66 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. +The **fs-policy plugin**: it records observed presence or absence and adds read-before-edit plus guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. ```ts import type { Context } from 'cordis' @@ -33,13 +33,13 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek | Event | This plugin's listener | |---|---| -| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | -| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. | -| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. | +| `fs/write-intent` | Unseen or observed absent → `{ kind: 'createIfAbsent' }`; observed present → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | +| `fs/edit-intent` | Unseen → `FS_NOT_OBSERVED`; observed absent → `FS_NOT_FOUND`; observed present → `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. | +| `fs/observed` | Records `{ kind: 'present', version }` or `{ kind: 'absent' }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. | ## Observed state is the prior-observation record; freshness is provider CAS -Observed state is a weak owner-to-target version map updated after every successful read or mutation; presence alone is the prior-observation record. The plugin performs no filesystem I/O: it supplies the observed version to the provider's atomic mutation guard. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions. +Observed state is a weak owner-to-target map with three logical states: unseen, confirmed absent, or present at a version. A successful file read or mutation records presence; a `read`/`view` metadata miss records absence before returning `FS_NOT_FOUND`. The plugin performs no filesystem I/O: it converts that state into a provider guard. Presence supplies the observed version, while absence lets only a `createIfAbsent` write proceed; edit has no version basis and returns `FS_NOT_FOUND`. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions. ## Single-slot, first-wins @@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no #### What the model sees -This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown. +This plugin adds no prompt or schema. It rejects an edit without a prior observation with code `FS_NOT_OBSERVED` and exact message `edit requires reading "" first`; editing a target just observed absent returns `FS_NOT_FOUND`. Guarded mutations whose positive observation is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code. Following the stale remedy on an externally deleted target now records absence: the next guarded write may recreate it with `createIfAbsent`, while the provider atomically preserves any concurrent creator. #### Token effect diff --git a/packages/fs/fs-policy/README.zh.md b/packages/fs/fs-policy/README.zh.md index 2e38d63d8e..26e0199e6d 100644 --- a/packages/fs/fs-policy/README.zh.md +++ b/packages/fs/fs-policy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**fs-policy 插件**:它在 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加已观察状态、编辑前读取和版本防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的策略层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的策略。 +**fs-policy 插件**:它记录观测到的存在或缺失状态,并在 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加编辑前读取和带防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的政策层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的政策。 ```ts import type { Context } from 'cordis' @@ -33,13 +33,13 @@ await ctx.plugin(FsPolicy) | 事件 | 本插件的监听器 | |---|---| -| `fs/write-intent` | 先前未观察 → `{ kind: 'createIfAbsent' }`;先前已观察 → `{ kind: 'replaceIfVersion', version: vObserved }`。单槽决策;不调用 `next()`。 | -| `fs/edit-intent` | 要求该所有者先前已观察,否则抛出 `FS_NOT_OBSERVED`;返回 `{ version: vObserved }` 作为 CAS 基础。单槽决策;不调用 `next()`。 | -| `fs/observed` | 为该所有者与目标记录 `{ version }`。同步、只有副作用的 `WeakMap.set`。 | +| `fs/write-intent` | 未见或已观测为缺失 → `{ kind: 'createIfAbsent' }`;已观测为存在 → `{ kind: 'replaceIfVersion', version: vObserved }`。单槽决策;不调用 `next()`。 | +| `fs/edit-intent` | 未见 → `FS_NOT_OBSERVED`;已观测为缺失 → `FS_NOT_FOUND`;已观测为存在 → 返回 `{ version: vObserved }` 作为 CAS 基础。单槽决策;不调用 `next()`。 | +| `fs/observed` | 为该所有者与目标记录 `{ kind: 'present', version }` 或 `{ kind: 'absent' }`。同步、只有副作用的 `WeakMap.set`。 | ## 已观察状态是先前观察记录;新鲜度由提供方 CAS 保证 -已观察状态是一张以所有者为弱键、记录各目标版本的映射表,每次读取或变更成功后都会更新;记录存在本身就是先前观察凭据。插件不执行文件系统 I/O:它把观察到的版本提供给提供方的原子变更防护。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。 +观测状态是一张以所有者为弱键、记录各目标的映射表,具有三种逻辑状态:未见、确认缺失、存在于某个版本。成功读取文件或变更会记录存在;`read`/`view` 的元数据未命中会在返回 `FS_NOT_FOUND` 前记录缺失。插件不执行文件系统 I/O:它把该状态转换为提供方防护。存在状态提供观测到的版本;缺失状态只允许 `createIfAbsent` 写入继续,edit 因没有版本基准而返回 `FS_NOT_FOUND`。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。 ## 单槽、先到者胜 @@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy) #### 模型看到的内容 -该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示。 +该插件不添加提示词或 schema。没有先前观测时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "" first` 拒绝编辑;编辑刚被观测为缺失的目标会返回 `FS_NOT_FOUND`。正向观测陈旧时,带防护的变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码。外部删除目标后,遵循陈旧恢复指令会记录缺失:下一次带防护的写入可以通过 `createIfAbsent` 重新创建该目标,而提供方会以原子方式保留任何并发创建者写入的文件。 #### Token 影响 diff --git a/packages/fs/fs-policy/src/index.ts b/packages/fs/fs-policy/src/index.ts index 3765a7e512..c7feb8c484 100644 --- a/packages/fs/fs-policy/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -1,14 +1,15 @@ /** * Event-only filesystem observation policy; it registers no service. A weak owner/target map - * records every successful read or mutation, single-slot intent listeners supply that version, - * and the provider performs the atomic freshness check. Without this plugin, tools retain the - * bare provider's unconditional mutation behavior. See the package README for composition rules. + * records every authoritative presence/absence observation, single-slot intent listeners derive + * guards from that state, and the provider performs the atomic freshness/no-clobber check. Without + * this plugin, tools retain the bare provider's unconditional mutation behavior. See the package + * README for composition rules. * @module @deepseek-ai/dsh-fs-policy */ import type { Context } from 'cordis' import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' import type { FsPolicyExec } from './types.ts' export type { FsPolicyExec } from './types.ts' @@ -21,9 +22,10 @@ class ObservedStateGate { /** * Observed-file state, keyed first by the owner object (weakly held, so a * collected session frees its state), then by {@link FsTarget.targetKey}. An - * entry's PRESENCE is the prior-observation record. + * entry's presence is the prior-observation record; its discriminant keeps + * confirmed absence distinct from an unseen target. */ - private observed = new WeakMap>() + private observed = new WeakMap>() /** * Derive the observed-state owner from the opaque event actor — normally the @@ -38,17 +40,17 @@ class ObservedStateGate { return (actor as FsPolicyExec | undefined)?.agent?.session } - private get(owner: object, targetKey: string): FsVersion | undefined { + private get(owner: object, targetKey: string): FsObservation | undefined { return this.observed.get(owner)?.get(targetKey) } - private set(owner: object, targetKey: string, version: FsVersion): void { + private set(owner: object, targetKey: string, observation: FsObservation): void { let byTarget = this.observed.get(owner) if (!byTarget) { byTarget = new Map() this.observed.set(owner, byTarget) } - byTarget.set(targetKey, version) + byTarget.set(targetKey, observation) } /** Drop all recorded state (HMR safety / disposal). */ @@ -57,33 +59,38 @@ class ObservedStateGate { } /** - * Decide the write intent: no prior observation ⇒ `createIfAbsent` (only - * new files can be created blindly); a prior observation ⇒ `replaceIfVersion` - * at the observed version (existing files replaced only if unchanged). + * Decide the write intent: unseen or confirmed absent ⇒ `createIfAbsent`; + * confirmed present ⇒ `replaceIfVersion` at the observed version. */ writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent { const owner = this.owner(actor) const prior = owner ? this.get(owner, target.targetKey) : undefined - return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' } + return prior?.kind === 'present' + ? { kind: 'replaceIfVersion', version: prior.version } + : { kind: 'createIfAbsent' } } /** - * Decide the edit version guard: requires a prior observation by this owner - * (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis. + * Decide the edit version guard: unseen rejects with `FS_NOT_OBSERVED`, + * confirmed absence rejects with `FS_NOT_FOUND`, and presence supplies the + * observed version as the CAS basis. */ editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } { const owner = this.owner(actor) const prior = owner ? this.get(owner, target.targetKey) : undefined - if (!owner || !prior) { + if (!owner || prior === undefined) { throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') } - return { version: prior } + if (prior.kind === 'absent') { + throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND') + } + return { version: prior.version } } - /** Record a successful read/write/edit: this owner observed this target at this version. */ - observe(target: FsTarget, version: FsVersion, actor: object | undefined): void { + /** Record an authoritative present or absent observation for this owner and target. */ + observe(target: FsTarget, observation: FsObservation, actor: object | undefined): void { const owner = this.owner(actor) - if (owner) this.set(owner, target.targetKey, version) + if (owner) this.set(owner, target.targetKey, observation) } } @@ -114,9 +121,10 @@ export function apply(ctx: Context): void { // fs/edit-intent: occupy the single decision slot — do not call next(). ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor))) - // fs/observed must remain synchronous and non-throwing: the mutation already succeeded, and - // emit does not await promises. WeakMap.set satisfies that contract. - ctx.on('fs/observed', (target, version, actor) => { - gate.observe(target, version, actor) + // fs/observed must remain synchronous and non-throwing: emit does not await + // promises, and successful mutations have already committed. WeakMap.set + // satisfies that contract for both presence and absence. + ctx.on('fs/observed', (target, observation, actor) => { + gate.observe(target, observation, actor) }) } diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts index 85c6ae0b52..2c977cc3be 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import type { FsObservation, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy' @@ -11,6 +11,8 @@ function target(path: string): FsTarget { return { targetKey: FsTargetKey(path), displayPath: path } } const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } }) +const present = (version: string): FsObservation => ({ kind: 'present', version: FsVersion(version) }) +const absent: FsObservation = { kind: 'absent' } /** Dispatch the write-intent waterfall with the bare default thunk. */ function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise { @@ -64,9 +66,16 @@ describe('write-intent decision', () => { it('an observed target decides replaceIfVersion at the observed version', async () => { const { ctx } = await setup() const exec = ownerExec({}) - ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec) + ctx.emit('fs/observed', target('a.txt'), present('v7'), exec) expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) }) + + it('a target observed absent decides createIfAbsent', async () => { + const { ctx } = await setup() + const exec = ownerExec({}) + ctx.emit('fs/observed', target('a.txt'), absent, exec) + expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'createIfAbsent' }) + }) }) describe('edit-intent decision', () => { @@ -88,16 +97,23 @@ describe('edit-intent decision', () => { it('returns the observed version as the CAS basis after an observation', async () => { const { ctx } = await setup() const exec = ownerExec({}) - ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec) + ctx.emit('fs/observed', target('a.txt'), present('v3'), exec) expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) }) + + it('rejects editing a target observed absent with FS_NOT_FOUND', async () => { + const { ctx } = await setup() + const exec = ownerExec({}) + ctx.emit('fs/observed', target('a.txt'), absent, exec) + await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) }) describe('observed-state is the prior-observation record', () => { it('a read observation authorizes an in-place write at that version', async () => { const { ctx } = await setup() const exec = ownerExec({}) - ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read + ctx.emit('fs/observed', target('a.txt'), present('v0'), exec) // a read expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) @@ -105,19 +121,34 @@ describe('observed-state is the prior-observation record', () => { const { ctx } = await setup() const exec = ownerExec({}) // A create records v1; the follow-up edit guards against v1 with no read. - ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec) + ctx.emit('fs/observed', target('a.txt'), present('v1'), exec) expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' }) // The edit records v2; a second edit guards against v2. - ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec) + ctx.emit('fs/observed', target('a.txt'), present('v2'), exec) expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' }) }) it('a no-owner observation records nothing', async () => { const { ctx } = await setup() - ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined) + ctx.emit('fs/observed', target('a.txt'), present('v0'), undefined) // Still unobserved for any owner. await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) + + it('supports present → absent → present transitions for one owner', async () => { + const { ctx } = await setup() + const exec = ownerExec({}) + const a = target('a.txt') + ctx.emit('fs/observed', a, present('v1'), exec) + expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'replaceIfVersion', version: 'v1' }) + + ctx.emit('fs/observed', a, absent, exec) + expect(await writeIntent(ctx, a, exec)).toEqual({ kind: 'createIfAbsent' }) + await expect(editIntent(ctx, a, exec)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + + ctx.emit('fs/observed', a, present('v2'), exec) + expect(await editIntent(ctx, a, exec)).toEqual({ version: 'v2' }) + }) }) describe('multi-owner isolation', () => { @@ -125,7 +156,7 @@ describe('multi-owner isolation', () => { const { ctx } = await setup() const a = ownerExec({}) const b = ownerExec({}) - ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) + ctx.emit('fs/observed', target('a.txt'), present('v0'), a) await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' }) }) @@ -134,7 +165,7 @@ describe('multi-owner isolation', () => { const { ctx } = await setup() const a = ownerExec({}) const b = ownerExec({}) - ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0 + ctx.emit('fs/observed', target('a.txt'), present('v0'), a) // A observed v0 // B never observed → createIfAbsent; A still holds v0 → replaceIfVersion. expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) @@ -164,7 +195,7 @@ describe('single-slot, first-wins', () => { return Promise.resolve(undefined) }) const exec = ownerExec({}) - ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) + ctx.emit('fs/observed', target('a.txt'), present('v0'), exec) await editIntent(ctx, target('a.txt'), exec) expect(secondRan).toBe(false) }) @@ -186,7 +217,7 @@ describe('disposal releases recorded state (HMR safety)', () => { const ctx = new Context() const exec = ownerExec({}) const fiber = await ctx.plugin(FsPolicy) - ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) + ctx.emit('fs/observed', target('a.txt'), present('v0'), exec) expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) await fiber.dispose() diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index a77f4c8e04..dabdcacd3c 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/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/fs/fs/README.md -README.md: c7c978b7530a9b26e178f164dae61ae60f8f321f -README.zh.md: 9761fc81bcb160edfc96d42cea45183abcfc087d +README.md: 2b10614c8bf3d7e4a26c7f3d90dbc127cfec4336 +README.zh.md: e0cbf058bb8d6b52e8c8e5a048cc8b3d757e67d0 diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index c7c978b753..2b10614c8b 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -30,14 +30,14 @@ A backend subclasses `FileSystem` and implements eleven primitives. | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here); consumers that need a byte ceiling enforce it while consuming the stream. | | `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. | -| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. | +| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. `createIfAbsent` must perform a no-replace publication so a creator racing the initial probe is preserved. | | `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. | The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity. ## The `fs/*` policy events -This package declares three events (see the generated region of [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. +This package declares three events (see the generated region of [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event carrying an `FsObservation` discriminated union: present with a version or confirmed absent. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. ## A provider contract, not the policy layer @@ -47,7 +47,7 @@ This package declares three events (see the generated region of [filesystem.md]( ## Vocabulary -`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. ## Model Experience diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 9761fc81bc..e0cbf058bb 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -30,14 +30,14 @@ | `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 | | `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责);需要字节上限的消费方在消费流时执行该上限。 | | `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列表失败。 | -| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。 | +| `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。`createIfAbsent` 必须以不替换的方式发布,使初始探测后抢先创建的文件得到保留。 | | `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 | 无论是否有版本防护,变更都在后端的每目标锁内运行,因此无条件写入/编辑仍是原子的;「无条件」只移除*版本*前置条件,不移除原子性。 ## `fs/*` 政策事件 -本包声明三个事件(见 [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface) 的生成区块),使发出方(`@deepseek-ai/dsh-tool-fs`)和政策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖政策插件。`fs/write-intent` 和 `fs/edit-intent` 是单槽决策 waterfall(监听器完整决策,绝不调用 `next()`);`fs/observed` 是发后即忘的记录事件。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent(智能体)/会话所有者结构。 +本包声明三个事件(见 [filesystem.md](../../../docs/subsystems/filesystem.md#cordis-surface) 的生成区块),使发出方(`@deepseek-ai/dsh-tool-fs`)和政策监听器(`@deepseek-ai/dsh-fs-policy`)共享词汇,而无需让发出方依赖政策插件。`fs/write-intent` 和 `fs/edit-intent` 是单槽决策 waterfall(监听器完整决策,绝不调用 `next()`);`fs/observed` 是发后即忘的记录事件,携带 `FsObservation` 可辨识联合:存在并带有版本,或确认缺失。它们只携带 `dsh-fs` 词汇和一个不透明 `object` 参与者,不含面向模型的概念或 agent(智能体)/会话所有者结构。 ## 提供方约定,不是政策层 @@ -47,7 +47,7 @@ ## 词汇 -`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。 +`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }` 与 `{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。 ## 模型体验 diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index b4e3884c3b..0be94dccc4 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -16,6 +16,7 @@ import type { FsEditRequest, FsInfo, FsPathInfo, + FsObservation, FsTarget, FsVersion, FsWriteIntent, @@ -33,6 +34,7 @@ export type { FsDirEntry, FsErrorCode, FsInfo, + FsObservation, FsPathInfo, FsTarget, FsWriteIntent, @@ -63,14 +65,15 @@ declare module 'cordis' { */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> /** - * Record a successful observation. Listeners must be synchronous recorders: - * throws fail the tool call and returned promises are not awaited. - * @param target - the target that was read/written/edited. - * @param version - the version the actor now holds as its observation. + * Record an authoritative positive or negative observation. Listeners must + * be synchronous recorders: throws fail the tool call and returned promises + * are not awaited. + * @param target - the target whose presence or absence was observed. + * @param observation - present with its version, or confirmed absent. * @param actor - the observing tool-execution context; undefined records nothing useful. * @mode emit */ - 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void + 'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void } } @@ -198,7 +201,7 @@ export abstract class FileSystem extends Service { * @param target - the resolved target to write. * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. - * @param signal - aborts before the atomic rename takes effect. + * @param signal - aborts before atomic publication takes effect. * @param sandboxPolicy - the per-call mode and workspace root this write * runs under; a sandboxing backend fences the write by it, the bare backend * ignores it. Omit to leave the backend its own default. @@ -219,7 +222,7 @@ export abstract class FileSystem extends Service { * @param target - the resolved target to edit. * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. - * @param signal - aborts before the atomic rename takes effect. + * @param signal - aborts before atomic publication takes effect. * @param sandboxPolicy - the per-call mode and workspace root this edit runs * under; a sandboxing backend fences the edit by it, the bare backend * ignores it. Omit to leave the backend its own default. diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts index 429c1cec41..62fec9a53c 100644 --- a/packages/fs/fs/src/invariant.ts +++ b/packages/fs/fs/src/invariant.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { FsTarget, FsVersion } from './types.ts' +import type { FsObservation, FsTarget } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-fs' @@ -24,8 +24,17 @@ const install: InvariantInstaller = (ctx, fail) => { && eventName !== 'fs/edit-intent' && eventName !== 'fs/observed') return validateTarget(args[0] as FsTarget, fail) - if (eventName === 'fs/observed' && (args[1] as FsVersion).length === 0) { - fail('fs/observed version must be non-empty') + if (eventName === 'fs/observed') { + const observation = args[1] as FsObservation + switch (observation.kind) { + case 'present': + if (observation.version.length === 0) fail('fs/observed present version must be non-empty') + break + case 'absent': + break + default: + fail('fs/observed kind must be present or absent') + } } }, { global: true }) } diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 6a34f3848f..e842d0d574 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -44,6 +44,15 @@ export function FsVersion(v: string): FsVersion { return v as FsVersion } +/** + * One authoritative observation of a target. A present observation carries the + * version used by guarded replacement; an absent observation authorizes only a + * guarded create, never an edit. + */ +export type FsObservation = + | { readonly kind: 'present'; readonly version: FsVersion } + | { readonly kind: 'absent' } + /** * A path resolved by a backend into a stable identity. `resolve()` produces * this; every other operation takes it. diff --git a/packages/fs/fs/tests/invariant.spec.ts b/packages/fs/fs/tests/invariant.spec.ts index c160e3d238..eecdea8766 100644 --- a/packages/fs/fs/tests/invariant.spec.ts +++ b/packages/fs/fs/tests/invariant.spec.ts @@ -28,17 +28,28 @@ describe('filesystem invariants', () => { ctx as never, 'fs/edit-intent', target(), undefined, () => Promise.resolve(undefined), )).resolves.toBeUndefined() - expect(() => { ctx.emit('fs/observed', target(), FsVersion('v1'), undefined) }).not.toThrow() + expect(() => { + ctx.emit('fs/observed', target(), { kind: 'present', version: FsVersion('v1') }, undefined) + }).not.toThrow() + expect(() => { ctx.emit('fs/observed', target(), { kind: 'absent' }, undefined) }).not.toThrow() expect(() => { ctx.emit('tools/change') }).not.toThrow() }) it('rejects empty target and version identities', async () => { const ctx = await setup() - expect(() => { ctx.emit('fs/observed', target(''), FsVersion('v1'), undefined) }) + expect(() => { + ctx.emit('fs/observed', target(''), { kind: 'present', version: FsVersion('v1') }, undefined) + }) .toThrow(/targetKey must be non-empty/) - expect(() => { ctx.emit('fs/observed', target('file:1', ''), FsVersion('v1'), undefined) }) + expect(() => { + ctx.emit('fs/observed', target('file:1', ''), { kind: 'present', version: FsVersion('v1') }, undefined) + }) .toThrow(/displayPath must be non-empty/) - expect(() => { ctx.emit('fs/observed', target(), FsVersion(''), undefined) }) - .toThrow(/version must be non-empty/) + expect(() => { + ctx.emit('fs/observed', target(), { kind: 'present', version: FsVersion('') }, undefined) + }).toThrow(/present version must be non-empty/) + expect(() => { + ctx.emit('fs/observed', target(), { kind: 'unknown' } as never, undefined) + }).toThrow(/kind must be present or absent/) }) }) diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index f6f036b33c..a301417b4b 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: c9375a3ee803db48c547c7553d10f9b5ebde3fd2 -README.zh.md: 70ff15f2beff2674719f3084594e5cc0c7309f68 +README.md: 6878584b72c3e799ca8a46ddc6fc2908f9bc3acf +README.zh.md: d8e92d96c60d30ec4aa943b77b882517324c4e52 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index c9375a3ee8..6878584b72 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -108,7 +108,7 @@ Prefix-stable while the visible tool definitions and order are unchanged. Regist #### What the model sees -A successful read is exactly ``, newline, `file`, newline, ``, numbered lines as `: `, a blank line, one footer, and ``. The footer is exactly `(Output capped. Showing lines -. Use offset= to continue.)`, `(Showing lines - of . Use offset= to continue.)`, or `(End of file - total lines)`. A long line ends exactly `... (line truncated to chars)`. +A successful read is exactly ``, newline, `file`, newline, ``, numbered lines as `: `, a blank line, one footer, and ``. The footer is exactly `(Output capped. Showing lines -. Use offset= to continue.)`, `(Showing lines - of . Use offset= to continue.)`, or `(End of file - total lines)`. A long line ends exactly `... (line truncated to chars)`. A missing read still returns `FS_NOT_FOUND`, but it records confirmed absence for the calling session; after an externally deleted file is re-read, a retried `write` can safely recreate it through the provider's no-replace guard. #### Token effect @@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 70ff15f2be..d8e92d96c6 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -108,7 +108,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -成功读取结果精确为 ``、换行、`file`、换行、``、形如 `: ` 的编号行、一个空行、一条 footer 和 ``。footer 精确为 `(Output capped. Showing lines -. Use offset= to continue.)`、`(Showing lines - of . Use offset= to continue.)` 或 `(End of file - total lines)`。长行结尾精确为 `... (line truncated to chars)`。 +成功读取结果精确为 ``、换行、`file`、换行、``、形如 `: ` 的编号行、一个空行、一条 footer 和 ``。footer 精确为 `(Output capped. Showing lines -. Use offset= to continue.)`、`(Showing lines - of . Use offset= to continue.)` 或 `(End of file - total lines)`。长行结尾精确为 `... (line truncated to chars)`。读取缺失目标仍返回 `FS_NOT_FOUND`,但会为调用会话记录确认缺失;外部删除的文件被重新读取后,重试的 `write` 可以通过提供方的不替换防护安全地重新创建该文件。 #### Token 影响 @@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index fcd04cb17c..7b2c3ae596 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -137,8 +137,8 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void { // model-facing remedy; anything else passes through. throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) } - // Record the observed version (a no-op when no policy plugin listens). - ctx.emit('fs/observed', target, outcome.version, exec) + // Record the present observation (a no-op when no policy plugin listens). + ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) return { path: target.displayPath, before: outcome.before, diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index a92fdcaad8..dc4f07e8fe 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -138,10 +138,13 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { const input = parseReadArgs(args, caps.limit) const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath)) - // One stat: type check + size routing + the version recorded as observed. + // One stat: absence observation OR type check + size routing + present version. // A concurrent write can only make a later guarded mutation fail stale and require reread. const info = await ctx.fs.stat(target, exec.signal) - if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (!info) { + ctx.emit('fs/observed', target, { kind: 'absent' }, exec) + throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + } if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') // Stream when the file is large OR size is unknown, so a size-less backend @@ -161,10 +164,10 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { lines: window.lines, totalLines: window.totalLines, } - // Record the observed version (a no-op when no policy plugin listens). The + // Record the present observation (a no-op when no policy plugin listens). The // read already succeeded; an fs/observed listener is contractually a // synchronous, side-effect-only recorder. - ctx.emit('fs/observed', target, info.version, exec) + ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec) return outcome }, // Result-time display: a `read` card carrying the structured line window a diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 56e2be488b..9a74dfc387 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -118,8 +118,8 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void { // model-facing remedy; anything else passes through. throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) } - // Record the observed version (a no-op when no policy plugin listens). - ctx.emit('fs/observed', target, outcome.version, exec) + // Record the present observation (a no-op when no policy plugin listens). + ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) return { path: target.displayPath, operation: outcome.operation, diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 3482b38569..d2a63e0a38 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -234,37 +234,33 @@ describe('default deployment (with dsh-fs-policy)', () => { }) }) - describe('deleted observed target (fail-closed corner)', () => { - it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => { + describe('deleted observed target', () => { + it('a failed reread records absence so write can safely recreate the file', async () => { await writeFile(join(dir, 'a.txt'), 'original') await call('read', { file_path: 'a.txt' }) await rm(join(dir, 'a.txt')) // out-of-band deletion - // Edit of the missing target: stale (the missing-target path shares the - // stale code and the re-read remedy). + // The original positive observation still protects the first mutation. const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' }) expect(edit.isError).toBe(true) expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) - - // Re-reading the missing file FAILS with FS_NOT_FOUND and records no - // observation, so the retried edit fails identically: the observed entry - // is never cleared for a deleted target. - const reread = await call('read', { file_path: 'a.txt' }) - expect(reread.isError).toBe(true) - expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) - const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' }) - expect(retriedEdit.isError).toBe(true) - expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) - - // Write cannot recreate it either: the stale observation still forces - // replaceIfVersion, which rejects a missing target ("file no longer exists"). - const write = await call('write', { file_path: 'a.txt', content: 'fresh' }) + const write = await call('write', { file_path: 'a.txt', content: 'premature' }) expect(write.isError).toBe(true) expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } }) - // The dead end lifts once the file exists again and is freshly observed. - await writeFile(join(dir, 'a.txt'), 'restored') - expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + // A read-not-found is an authoritative negative observation for this + // owner. It still fails as a read, but changes the next write guard. + const reread = await call('read', { file_path: 'a.txt' }) + expect(reread.isError).toBe(true) + expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) + + // Absence never authorizes edit: there is no content/version to edit. + const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' }) + expect(retriedEdit.isError).toBe(true) + expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) + + // The retried write uses createIfAbsent; the provider remains responsible + // for rejecting a concurrent creator at publication time. const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' }) expect(recovered.isError).toBe(false) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh') @@ -294,6 +290,20 @@ describe('default deployment (with dsh-fs-policy)', () => { expect(statSpy).not.toHaveBeenCalled() statSpy.mockRestore() }) + + it('a missing read still stats once and its recovery write stats zero times', async () => { + const statSpy = vi.spyOn(ctx.fs, 'stat') + const missing = await call('read', { file_path: 'missing.txt' }) + expect(missing.isError).toBe(true) + expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) + expect(statSpy).toHaveBeenCalledTimes(1) + + statSpy.mockClear() + const created = await call('write', { file_path: 'missing.txt', content: 'fresh' }) + expect(created.isError).toBe(false) + expect(statSpy).not.toHaveBeenCalled() + statSpy.mockRestore() + }) }) }) @@ -482,7 +492,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) // Reproduce an older concurrent read winning the observation race. - ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } }) + ctx.emit('fs/observed', target, { kind: 'present', version: firstInfo.version }, { agent: { session } }) const edit = await callOwned('edit', { file_path: 'a.txt', diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index b6733eb3b8..fd61c07154 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/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/fs/tool-str-replace-editor/README.md -README.md: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f -README.zh.md: a71f040f3b1383604fa1f151f797906dd343c49a +README.md: 1d6ce6fd801997dd21973fde1ffded84dbee1b5a +README.zh.md: 62728659fa8c8173ac42a42610f1a710aec6e386 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 97e9e0ab9a..1d6ce6fd80 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -13,7 +13,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w ## Tool -The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. +The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. A missing view records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. ## Model Experience diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index a71f040f3b..62728659fa 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -13,7 +13,7 @@ ## 工具 -schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 +schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。查看缺失目标时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 ## 模型体验 diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 6d0f65c48c..4b93c6d82c 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -105,6 +105,7 @@ async function statExisting( ): Promise { const info = await ctx.fs.stat(target, exec.signal) if (info === undefined) { + ctx.emit('fs/observed', target, { kind: 'absent' }, exec) throw new FsError( `The path ${target.displayPath} does not exist. Please provide a valid path.`, 'FS_NOT_FOUND', @@ -231,7 +232,7 @@ async function viewPath( throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE') } const content = await ctx.fs.readText(target, exec.signal) - ctx.emit('fs/observed', target, info.version, exec) + ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec) return formatFileView(target.displayPath, content, maxOutputChars, viewRange) } @@ -266,7 +267,7 @@ async function createFile( } catch (error: unknown) { throw policy.mapError(error, sandboxPolicy) } - ctx.emit('fs/observed', target, outcome.version, exec) + ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) return `New file created successfully at: ${target.displayPath}` } @@ -317,7 +318,7 @@ async function replaceInFile( } catch (error: unknown) { throw policy.mapError(error, sandboxPolicy) } - ctx.emit('fs/observed', target, outcome.version, exec) + ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) return `The file ${target.displayPath} has been edited successfully.` } @@ -359,7 +360,7 @@ async function insertInFile( } catch (error: unknown) { throw policy.mapError(error, sandboxPolicy) } - ctx.emit('fs/observed', target, outcome.version, exec) + ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) return `The file ${target.displayPath} has been edited successfully.` } diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 01f15284de..db0fe8dc35 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -196,6 +196,35 @@ describe('tool-str-replace-editor', () => { expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n') }) + it('a failed view records absence so create can recover after external deletion', async () => { + const { ctx, root, owner } = await setup({}, { fsPolicy: true }) + const sample = join(root, 'deleted.txt') + await writeFile(sample, 'original') + expect((await call(ctx, owner, { command: 'view', path: sample })).isError).toBe(false) + await rm(sample) + + const missing = await call(ctx, owner, { command: 'view', path: sample }) + expect(missing.isError).toBe(true) + expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) + + const edit = await call(ctx, owner, { + command: 'str_replace', + path: sample, + old_str: 'original', + new_str: 'edited', + }) + expect(edit.isError).toBe(true) + expect(edit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } }) + + const created = await call(ctx, owner, { + command: 'create', + path: sample, + file_text: 'fresh', + }) + expect(created.isError).toBe(false) + expect(await readFile(sample, 'utf8')).toBe('fresh') + }) + it('writes replacement text literally', async () => { const { ctx, root, owner } = await setup() const sample = join(root, 'literal.txt') diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index eb7f2a0eac..ef97c945c9 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -370,11 +370,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise', - jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this write\n * runs under; a sandboxing backend fences the write by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */', + jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before atomic publication takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this write\n * runs under; a sandboxing backend fences the write by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */', }, { signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy, ): Promise', - jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this edit runs\n * under; a sandboxing backend fences the edit by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */', + jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before atomic publication takes effect.\n * @param sandboxPolicy - the per-call mode and workspace root this edit runs\n * under; a sandboxing backend fences the edit by it, the bare backend\n * ignores it. Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */', }, ], }, @@ -1425,9 +1425,9 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'fs/observed', mode: 'emit', - signature: '\'fs/observed\'(target: FsTarget, version: FsVersion, actor: object | undefined): void', - jsDoc: '/**\n * Record a successful observation. Listeners must be synchronous recorders:\n * throws fail the tool call and returned promises are not awaited.\n * @param target - the target that was read/written/edited.\n * @param version - the version the actor now holds as its observation.\n * @param actor - the observing tool-execution context; undefined records nothing useful.\n * @mode emit\n */', - summary: 'Record a successful observation.', + signature: '\'fs/observed\'(target: FsTarget, observation: FsObservation, actor: object | undefined): void', + jsDoc: '/**\n * Record an authoritative positive or negative observation. Listeners must\n * be synchronous recorders: throws fail the tool call and returned promises\n * are not awaited.\n * @param target - the target whose presence or absence was observed.\n * @param observation - present with its version, or confirmed absent.\n * @param actor - the observing tool-execution context; undefined records nothing useful.\n * @mode emit\n */', + summary: 'Record an authoritative positive or negative observation.', }, { name: 'fs/write-intent', diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 71ed3be21d..02a3b4fc39 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -136,7 +136,7 @@ export function apply(ctx: Context, config: Config = {}): void { ctx.effect(function* () { yield async () => { await provider.dispose() } }, 'skill-local watcher') - ctx.on('fs/observed', (target, _version, actor) => { + ctx.on('fs/observed', (target, _observation, actor) => { if (mutationToolName(actor) === undefined) return provider.observeHostMutation(target.displayPath) }) diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 75f4a4ed65..8bb08d2947 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -496,7 +496,7 @@ describe('LocalSkillProvider', () => { ctx.emit( 'fs/observed', { targetKey: path as never, displayPath: path }, - FsVersion('failed-read'), + { kind: 'present', version: FsVersion('failed-read') }, { name: 'edit' }, ) expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) @@ -526,7 +526,7 @@ describe('LocalSkillProvider', () => { ctx.emit( 'fs/observed', { targetKey: path as never, displayPath: path }, - FsVersion('entry-failure'), + { kind: 'present', version: FsVersion('entry-failure') }, { name: 'write' }, ) } @@ -674,7 +674,7 @@ describe('LocalSkillProvider', () => { ctx.emit( 'fs/observed', { targetKey: displayPath as never, displayPath }, - FsVersion('observed'), + { kind: 'present', version: FsVersion('observed') }, actor, ) } @@ -689,7 +689,7 @@ describe('LocalSkillProvider', () => { ctx.emit( 'fs/observed', { targetKey: path as never, displayPath: path }, - FsVersion('observed'), + { kind: 'present', version: FsVersion('observed') }, { name: 'edit' }, ) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index de53d44755..fffde3ceb8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -262,6 +262,7 @@ export const LINK_MAP: Readonly> = { FsEditOutcome: 'filesystem.md', FsEditRequest: 'filesystem.md', FsInfo: 'filesystem.md', + FsObservation: 'filesystem.md', FsPathInfo: 'filesystem.md', FsPolicyExec: 'filesystem.md', FsTarget: 'filesystem.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index a68ab874f0..cd1cb3c0c1 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -253,7 +253,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-str-replace-editor', source: 'packages/fs/tool-str-replace-editor/src/index.ts', requires: ['ctx.tools', 'ctx.fs'], - writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'], + writes: ['tool/call', 'fs/observed after view presence/absence or successful mutation', 'tool/result'], async mount(ctx) { await ctx.plugin(LocalFileSystem) await ctx.plugin(ToolStrReplaceEditor) @@ -266,7 +266,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-fs', source: 'packages/fs/tool-fs/src/index.ts', requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'], - writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'], + writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'], async mount(ctx) { // The tool needs `fs`; the bare provider is sufficient because policy // changes behavior, not schema shape. diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5f57b5167d..cd9d020779 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -960,6 +960,11 @@ "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, + { + "doc": "docs/subsystems/filesystem.md", + "symbol": "FsObservation", + "source": "packages/fs/fs/src/types.ts" + }, { "doc": "docs/subsystems/filesystem.md", "symbol": "FsInfo", From 7132b9730eaf1468ed4a1df8c580a8c22cd3008a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:40:41 +0800 Subject: [PATCH 02/32] fix(fs): harden guarded-create publication --- ...9-filesystem-absence-observation.i18n.yaml | 4 +- ...26-08-09-filesystem-absence-observation.md | 8 +- ...08-09-filesystem-absence-observation.zh.md | 8 +- packages/e2b/fs-e2b/README.i18n.yaml | 4 +- packages/e2b/fs-e2b/README.md | 2 +- packages/e2b/fs-e2b/README.zh.md | 2 +- packages/e2b/fs-e2b/src/index.ts | 2 +- packages/e2b/fs-e2b/tests/filesystem.spec.ts | 27 ++++++- packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 4 +- packages/fs/fs-local/README.zh.md | 4 +- packages/fs/fs-local/src/fsio.ts | 77 +++++++++++++++---- packages/fs/fs-local/src/index.ts | 2 +- packages/fs/fs-local/tests/filesystem.spec.ts | 35 +++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 64 ++++++++++++++- 15 files changed, 207 insertions(+), 40 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml index 39e50ffe6b..6fbd47fc65 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.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/bug-fix/2026-08-09-filesystem-absence-observation.md -2026-08-09-filesystem-absence-observation.md: e0c5c8b1845550ac6380ea4b2782cde13cba3ce9 -2026-08-09-filesystem-absence-observation.zh.md: 808698a7cb7a3575ef500dced7a646691d65c424 +2026-08-09-filesystem-absence-observation.md: e01719651f54cafc86a3972690507b1ee9b95cc9 +2026-08-09-filesystem-absence-observation.zh.md: 126f85e95b1debbccddf82fda432d31a69250388 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md index e0c5c8b184..e01719651f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.md @@ -12,13 +12,13 @@ Treating a failed read as permission to create also exposes a second boundary. B ## Decision -`dsh-fs` owns an explicit observation union: `{ kind: 'present', version: FsVersion } | { kind: 'absent' }`. The `fs/observed` event carries that union. Successful reads and mutations emit present; a `read` or `str_replace_editor view` metadata miss emits absent synchronously before returning `FS_NOT_FOUND`. Other read failures do not manufacture absence. +`dsh-fs` owns an explicit observation union: `{ kind: 'present', version: FsVersion } | { kind: 'absent' }`. The `fs/observed` event carries that union. Successful reads and mutations emit present; a metadata miss from `read` or the `str_replace_editor` `view`, `str_replace`, or `insert` command emits absent synchronously before returning `FS_NOT_FOUND`. Other read failures do not manufacture absence. `dsh-fs-policy` stores three logical states per owner and target without injecting or calling `ctx.fs`: missing map entry is unseen, `absent` is confirmed absence, and `present(version)` is a replacement/edit basis. Write maps unseen and absent to the existing `createIfAbsent` intent and present to `replaceIfVersion`. Edit maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. A successful create or mutation replaces absence with its produced present version. -Every provider must enforce `createIfAbsent` at the publication point, not only at its initial probe. `dsh-fs-local` stages and fsyncs in a private sibling directory, then hard-links the staged file to the destination; an existing destination makes the no-replace link fail and preserves the competitor. `dsh-fs-e2b` uses remote `ln` with an explicit created/existing result and derives the committed target version from metadata obtained before the non-cancellable commit. Replacements and bare unconditional writes retain their existing publication paths. +Every provider must enforce `createIfAbsent` at the publication point, not only at its initial probe. `dsh-fs-local` stages and fsyncs in a private sibling directory, then hard-links the staged file to the destination; after a failed link it inspects the destination entry so a regular-file collision returns `FS_NOT_OBSERVED`, a non-regular entry returns `FS_NOT_REGULAR_FILE`, and a failure against a still-missing target returns `FS_IO_ERROR`. `dsh-fs-e2b` uses remote `ln -T` with an explicit created/existing result and derives the committed target version from metadata obtained before the non-cancellable commit. Replacements and bare unconditional writes retain their existing publication paths. -This decision does not claim cross-process linearizability for `replaceIfVersion`: the provider version check and replacement remain protected only against writers represented by the provider's own lock and detectable metadata. The narrower guarantee is exact and sufficient for absence recovery: guarded creation never clobbers a target that appears before publication. +This decision does not claim cross-process linearizability for `replaceIfVersion`: the provider version check and replacement remain protected only against writers represented by the provider's own lock and detectable metadata. The narrower guarantee is exact and sufficient for absence recovery: guarded creation never clobbers a target that appears before publication. Local guarded creation requires hard-link support; once any local publication succeeds, staging cleanup is best effort because private residue cannot make the committed write false. ## Alternatives considered @@ -29,7 +29,7 @@ This decision does not claim cross-process linearizability for `replaceIfVersion ## Consequences -The first mutation after an unobserved external deletion still fails `FS_STALE_VERSION`; the user or model must follow the existing reread remedy. That missing reread returns `FS_NOT_FOUND` while changing policy state, after which edit remains forbidden and write may recreate the path. If another writer wins the create race, the retry returns `FS_NOT_OBSERVED` and leaves the winner intact. +The first mutation after an unobserved external deletion still fails `FS_STALE_VERSION`; the user or model must follow the existing reread remedy. That missing reread returns `FS_NOT_FOUND` while changing policy state, after which edit remains forbidden and write may recreate the path. If another writer wins the create race, the retry returns `FS_NOT_OBSERVED` and leaves the winner intact; a competing directory, special entry, or dangling symbolic link instead returns `FS_NOT_REGULAR_FILE` without prescribing another read. The observation payload is a package-owned event contract change, so every producer, listener, invariant, generated Cordis catalog, subsystem document, and both filesystem tool families move together. The policy keeps its one-stat read and zero-stat write/edit budget, owner isolation, disposal behavior, and optional deployment boundary from the [event-gate decision](../architecture/2026-06-26-file-context-as-event-gate.md). diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md index 808698a7cb..126f85e95b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-09-filesystem-absence-observation.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -`dsh-fs` 拥有一个显式观测联合类型:`{ kind: 'present', version: FsVersion } | { kind: 'absent' }`。`fs/observed` 事件携带该联合类型。成功的读取与变更发出存在观测;`read` 或 `str_replace_editor view` 的元数据未命中会在返回 `FS_NOT_FOUND` 前同步发出缺失观测。其他读取失败不会产生缺失观测。 +`dsh-fs` 拥有一个显式观测联合类型:`{ kind: 'present', version: FsVersion } | { kind: 'absent' }`。`fs/observed` 事件携带该联合类型。成功的读取与变更发出存在观测;`read` 的元数据未命中,或 `str_replace_editor` 的 `view`、`str_replace`、`insert` 命令发生元数据未命中时,都会在返回 `FS_NOT_FOUND` 前同步发出缺失观测。其他读取失败不会产生缺失观测。 `dsh-fs-policy` 按所有者与目标存储三种逻辑状态,既不注入也不调用 `ctx.fs`:映射中无条目即未见,`absent` 表示确认缺失,`present(version)` 是替换/编辑基准。写入把未见和缺失映射到现有 `createIfAbsent` 意图,把存在映射到 `replaceIfVersion`。编辑把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。成功创建或变更后,系统会用其产生的存在版本取代缺失状态。 -每个提供方都必须在发布点执行 `createIfAbsent`,不能只在初始探测时执行。`dsh-fs-local` 在私有同级目录中暂存并执行 fsync,再通过硬链接把暂存文件发布到目标位置;目标已存在时,不替换链接会失败,并保留竞争创建者写入的文件。`dsh-fs-e2b` 使用远程 `ln` 返回明确的已创建/已存在结果,并根据不可取消提交前取得的元数据推导已提交目标的版本。替换操作和裸无条件写入仍沿用现有发布路径。 +每个提供方都必须在发布点执行 `createIfAbsent`,不能只在初始探测时执行。`dsh-fs-local` 在私有同级目录中暂存并执行 fsync,再通过硬链接把暂存文件发布到目标位置;链接失败后,它会检查目标条目:与普通文件冲突时返回 `FS_NOT_OBSERVED`,条目非普通时返回 `FS_NOT_REGULAR_FILE`,目标仍然缺失时返回 `FS_IO_ERROR`。`dsh-fs-e2b` 使用远程 `ln -T` 返回明确的已创建/已存在结果,并根据不可取消提交前取得的元数据推导已提交目标的版本。替换操作和裸无条件写入仍沿用现有发布路径。 -本决策不宣称 `replaceIfVersion` 具有跨进程线性一致性:提供方的版本检查与替换仍只能防范被其自身锁纳入协调的写入方,以及能通过元数据检测到的写入方。更窄的保证边界准确且足以支持缺失恢复:带防护的创建绝不会覆盖在发布前出现的目标。 +本决策不宣称 `replaceIfVersion` 具有跨进程线性一致性:提供方的版本检查与替换仍只能防范被其自身锁纳入协调的写入方,以及能通过元数据检测到的写入方。更窄的保证边界准确且足以支持缺失恢复:带防护的创建绝不会覆盖在发布前出现的目标。本地带防护的创建要求支持硬链接;任何本地发布一旦成功,暂存清理便采用尽力而为语义,因为私有残留无法否定已提交写入的成功。 ## 曾考虑的替代方案 @@ -29,7 +29,7 @@ Status: implemented ## 影响 -尚未观测到外部删除时,第一次变更仍以 `FS_STALE_VERSION` 失败;用户或模型必须遵循现有的重新读取恢复指令。该次针对缺失目标的重新读取会返回 `FS_NOT_FOUND` 并同时改变策略状态,此后 edit 仍被禁止,而 write 可以重新创建该路径。如果另一个写入方赢得创建竞态,本次重试会返回 `FS_NOT_OBSERVED`,并保留获胜方写入的文件。 +尚未观测到外部删除时,第一次变更仍以 `FS_STALE_VERSION` 失败;用户或模型必须遵循现有的重新读取恢复指令。该次针对缺失目标的重新读取会返回 `FS_NOT_FOUND` 并同时改变策略状态,此后 edit 仍被禁止,而 write 可以重新创建该路径。如果另一个写入方赢得创建竞态,本次重试会返回 `FS_NOT_OBSERVED`,并保留获胜方写入的文件;若竞态目标是目录、特殊条目或悬空符号链接,则改为返回 `FS_NOT_REGULAR_FILE`,且不会要求再次读取。 观测载荷是由包拥有的事件约定变更,因此所有生产方、监听器、不变式、生成的 Cordis 目录、子系统文档以及两套文件系统工具都必须同步更新。策略保留[事件门禁决策](../architecture/2026-06-26-file-context-as-event-gate.md)确立的 read 一次 `stat`、write/edit 零次 `stat` 预算、所有者隔离、dispose 行为和可选部署边界。 diff --git a/packages/e2b/fs-e2b/README.i18n.yaml b/packages/e2b/fs-e2b/README.i18n.yaml index 4e29843fad..64460f7e87 100644 --- a/packages/e2b/fs-e2b/README.i18n.yaml +++ b/packages/e2b/fs-e2b/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/e2b/fs-e2b/README.md -README.md: 039ad72a9651a1e8907c2c63b8c83d113aedbb4e -README.zh.md: be97bbd92edb528f9560518398a0cb70f36b992b +README.md: 1b66e84defb56cbfaa4a91d6ba6b48377fb52ca9 +README.zh.md: d9cd3ce1e109bf6b0b7fae02157d1ec6be51e575 diff --git a/packages/e2b/fs-e2b/README.md b/packages/e2b/fs-e2b/README.md index 039ad72a96..1b66e84def 100644 --- a/packages/e2b/fs-e2b/README.md +++ b/packages/e2b/fs-e2b/README.md @@ -9,7 +9,7 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide - **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute. - **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules. - **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing. -- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln` instead, making the commit atomically no-replace; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. +- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln -T` instead, making the commit atomically no-replace even when a directory appears at the destination; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. - **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before publication. The signal is not forwarded into the rename or guarded-link commit, so cancellation cannot interrupt atomic publication or turn a committed write into a reported failure. The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only. diff --git a/packages/e2b/fs-e2b/README.zh.md b/packages/e2b/fs-e2b/README.zh.md index be97bbd92e..d9cd3ce1e1 100644 --- a/packages/e2b/fs-e2b/README.zh.md +++ b/packages/e2b/fs-e2b/README.zh.md @@ -9,7 +9,7 @@ - **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;GNU `realpath -mz` 提供规范化目标身份,且不要求最终文件存在;ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam;目录列表会复用已返回的元数据,并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。 - **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。 - **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。 -- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln` 发布,使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。 +- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln -T` 发布,即使目标位置出现目录,也能使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。 - **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在发布前立即检查。信号不会传入 rename 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败。 该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。 diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index 04b23a6775..b26338ffa4 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -489,7 +489,7 @@ export class E2BFileSystem extends FileSystem { assertNotAborted(signal, 'write') const targetArg = quoteE2BShellArg(targetPath) const publication = await sandbox.commands.run( - `if ln -- ${quoteE2BShellArg(temporary)} ${targetArg}; then printf created; elif test -e ${targetArg} || test -L ${targetArg}; then printf exists; else exit 1; fi`, + `if ln -T -- ${quoteE2BShellArg(temporary)} ${targetArg}; then printf created; elif test -e ${targetArg} || test -L ${targetArg}; then printf exists; else exit 1; fi`, commandOpts(undefined), ) if (publication.stdout === 'exists') { diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 973cf7c5c8..6ee3df6543 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -52,7 +52,10 @@ class FakeRemote { nextRemoveError: unknown canonicalOutput: string | undefined abortAfterRename: AbortController | undefined - competitorBeforeLink: { path: string; data: string } | undefined + competitorBeforeLink: + | { path: string; kind: 'file'; data: string } + | { path: string; kind: 'directory' } + | undefined guardedLinkOutput: string | undefined disappearOnInfo = new Set() private clock = 1 @@ -257,7 +260,7 @@ class FakeRemote { const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command) if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8) const guardedLink = new RegExp( - "^if ln -- '([^']+)' '([^']+)'; then printf created; " + "^if ln -T -- '([^']+)' '([^']+)'; then printf created; " + "elif test -e '[^']+' \\|\\| test -L '[^']+'; then printf exists; else exit 1; fi$", ).exec(command) if (guardedLink !== null) { @@ -269,7 +272,8 @@ class FakeRemote { return { exitCode: 0, stdout, stderr: '' } } if (this.competitorBeforeLink?.path === to) { - this.file(to, this.competitorBeforeLink.data) + if (this.competitorBeforeLink.kind === 'directory') this.dir(to) + else this.file(to, this.competitorBeforeLink.data) this.competitorBeforeLink = undefined } if (this.nodes.has(to)) return { exitCode: 0, stdout: 'exists', stderr: '' } @@ -555,7 +559,7 @@ describe('E2BFileSystem atomic writes and edits', () => { it('preserves a competitor created after the guarded-create probe', async () => { const remote = new FakeRemote() - remote.competitorBeforeLink = { path: '/workspace/race.txt', data: 'competitor' } + remote.competitorBeforeLink = { path: '/workspace/race.txt', kind: 'file', data: 'competitor' } const { fs } = await setup(remote) await expectCode( @@ -567,6 +571,21 @@ describe('E2BFileSystem atomic writes and edits', () => { expect(remote.removals).toHaveLength(1) }) + it('preserves a competing directory during guarded-create publication', async () => { + const remote = new FakeRemote() + remote.competitorBeforeLink = { path: '/workspace/race-dir', kind: 'directory' } + const { fs } = await setup(remote) + + await expectCode( + fs.writeText(await fs.resolve('race-dir'), 'ours', { kind: 'createIfAbsent' }), + 'FS_NOT_OBSERVED', + ) + expect(remote.nodes.get('/workspace/race-dir')?.type).toBe(FileType.DIR) + expect(remote.nodes.has('/workspace/race-dir/content')).toBe(false) + expect(remote.links).toHaveLength(0) + expect(remote.removals).toHaveLength(1) + }) + it('rejects an invalid guarded-create publication response before claiming success', async () => { const remote = new FakeRemote() remote.guardedLinkOutput = 'unexpected' diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 6aaf691eef..ef76470394 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/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/fs/fs-local/README.md -README.md: ac2f271ad9651797e1aceeba49e7a52533405f10 -README.zh.md: 633d55fe06b2261d9d5b52184987811c87c8f155 +README.md: 7b993fa123d13833313ecf3f78c64e466b960d5d +README.zh.md: 428719137f988395b76513eab2c3f76ce4f331e5 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index ac2f271ad9..7b993fa123 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. -- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). +- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a regular file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`, while a non-regular path entry is preserved and rejected with `FS_NOT_REGULAR_FILE`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. @@ -40,3 +40,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. - **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits. - **The per-target mutation lock is in-process only** — guarded create still uses an atomic no-replace publication across processes, but replacement writers in another process are caught only when the optional version guard observes their metadata change; they are never serialized. +- **Guarded creation requires hard-link support** — filesystems or mounts that reject hard-link publication cannot serve `createIfAbsent`; the provider preserves the missing target and reports `FS_IO_ERROR`. +- **Post-commit cleanup is best effort** — a successful publication remains successful if removal of its owner-only staging directory fails, leaving private residue for later operator cleanup. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 633d55fe06..428719137f 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。 - **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。 -- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 +- **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 - **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。 包根 SDK 接口包含默认/具名 `LocalFileSystem` 类和 `Config`。原始 I/O 位于 `src/fsio.ts`(不依赖 Cordis,单独进行单元测试);`src/index.ts` 是轻量服务接线。 @@ -40,3 +40,5 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 - **二进制检测不对称**:读取只对前 8192 字节执行 NUL 采样,编辑则扫描整个 buffer,因此 NUL 出现在后部的文件可以读取,但编辑会被拒绝。 - **每目标变更锁仅限进程内**:即使跨进程,带防护的创建仍采用原子且不替换的发布方式;但只有当可选版本防护观察到元数据变化时,系统才能发现其他进程中的替换写入方,且绝不会将其串行化。 +- **带防护的创建要求支持硬链接**:拒绝硬链接发布的文件系统或挂载点无法支持 `createIfAbsent`;提供方会使目标保持缺失状态并报告 `FS_IO_ERROR`。 +- **提交后清理采用尽力而为语义**:如果移除仅所有者可访问的暂存目录失败,成功发布仍视为成功,并留下私有残留供运维人员后续清理。 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 071ad31637..24ad9b929f 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -91,6 +91,10 @@ export interface FsIoInternals { replaceFile?: (replaced: string, replacement: string) => Promise /** Override the hard-link no-replace publication boundary. */ linkFile?: (existingPath: string, newPath: string) => Promise + /** Override target inspection after guarded publication fails. */ + inspectPublicationTarget?: (path: string) => Promise + /** Override staging-directory removal for commit-point failure coverage. */ + removeStagingDir?: (stagingDir: string) => Promise /** Test hook after the temp file is written/synced but before final chmod+publication. */ inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise } @@ -413,9 +417,13 @@ export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal // --- Writing --- -async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise { +async function removeStagingDirOrThrow( + stagingDir: string, + originalError: unknown, + removeStagingDir: (path: string) => Promise, +): Promise { try { - await rm(stagingDir, { recursive: true, force: true }) + await removeStagingDir(stagingDir) } catch (cleanupError: unknown) { /* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */ throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError }) @@ -423,6 +431,43 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow throw originalError } +async function throwGuardedCreateFailure( + error: unknown, + absolutePath: string, + displayPath: string, + inspectPublicationTarget: (path: string) => Promise, +): Promise { + let existing: BigIntStats | undefined + try { + existing = await inspectPublicationTarget(absolutePath) + } catch (metadataError: unknown) { + if (!isENOENT(metadataError) && !isENOTDIR(metadataError)) { + throw new FsError(`cannot write "${displayPath}": ${errorMessage(metadataError)}`, 'FS_IO_ERROR', { cause: metadataError }) + } + } + + // Link errno values vary by platform and filesystem. Inspect the target entry + // after failure so a collision is not confused with missing hard-link support. + if (existing !== undefined) { + if (!existing.isFile()) { + throw new FsError(`cannot write "${displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE', { cause: error }) + } + throw new FsError( + `cannot overwrite existing "${displayPath}" without reading it first`, + 'FS_NOT_OBSERVED', + { cause: error }, + ) + } + if (isEEXIST(error)) { + throw new FsError( + `cannot overwrite existing "${displayPath}" without reading it first`, + 'FS_NOT_OBSERVED', + { cause: error }, + ) + } + throw new FsError(`cannot write "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error }) +} + /** * Atomically replace a file through a private, synced staging file in the same directory. * POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file @@ -434,8 +479,9 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow * inert as a mode on Windows but identifies replacement security semantics. * @param signal - cancellation checked before final publication. * @param internals - Test hook for pinning temp names and observing the staged file. - * @param createIfAbsent - publish with a hard-link no-replace primitive; a - * concurrent creator is preserved and rejected with `FS_NOT_OBSERVED`. + * @param createIfAbsent - when provided, publish with a hard-link no-replace + * primitive; a concurrent creator's file is preserved and this write is + * rejected with `FS_NOT_OBSERVED` using the supplied display path. */ export async function writeFileAtomic( absolutePath: string, @@ -443,7 +489,7 @@ export async function writeFileAtomic( mode: number | undefined, signal: AbortSignal | undefined, internals: FsIoInternals = {}, - createIfAbsent = false, + createIfAbsent?: { displayPath: string }, ): Promise { throwIfAborted(signal, 'write') const directory = dirname(absolutePath) @@ -458,6 +504,10 @@ export async function writeFileAtomic( const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32 const replaceFile = internals.replaceFile ?? replaceFileWin32 const linkFile = internals.linkFile ?? link + const inspectPublicationTarget = internals.inspectPublicationTarget + ?? (path => lstat(path, { bigint: true })) + const removeStagingDir = internals.removeStagingDir + ?? (path => rm(path, { recursive: true, force: true })) let handle: Awaited> | undefined let stagingCreated = false try { @@ -478,16 +528,11 @@ export async function writeFileAtomic( handle = undefined throwIfAborted(signal, 'write') - if (createIfAbsent) { + if (createIfAbsent !== undefined) { try { await linkFile(tempPath, absolutePath) } catch (error: unknown) { - if (!isEEXIST(error)) throw error - throw new FsError( - `cannot overwrite existing "${absolutePath}" without reading it first`, - 'FS_NOT_OBSERVED', - { cause: error }, - ) + await throwGuardedCreateFailure(error, absolutePath, createIfAbsent.displayPath, inspectPublicationTarget) } } else if (platform === 'win32' && mode !== undefined) { try { @@ -501,7 +546,11 @@ export async function writeFileAtomic( } else { await rename(tempPath, absolutePath) } - await rm(stagingDir, { recursive: true, force: true }) + try { + await removeStagingDir(stagingDir) + } catch (_committedStagingCleanupFailure) { + // The target is committed; owner-only staging residue cannot turn that write into a failure. + } } catch (error: unknown) { /* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */ let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error @@ -514,7 +563,7 @@ export async function writeFileAtomic( } } if (!stagingCreated) throw failure - return removeStagingDirOrThrow(stagingDir, failure) + return removeStagingDirOrThrow(stagingDir, failure, removeStagingDir) } } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 6be6a4402b..9cd4be41b8 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -173,7 +173,7 @@ export class LocalFileSystem extends FileSystem { existing?.mode, signal, this.internals, - expected?.kind === 'createIfAbsent', + expected?.kind === 'createIfAbsent' ? { displayPath: target.displayPath } : undefined, ) const after = await probe(target.targetKey) return { diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 531be25360..f6736ff9c3 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -308,6 +308,41 @@ describe('writeText', () => { expect(await readFile(path, 'utf8')).toBe('competitor') }) + it('reports a createIfAbsent race with the unresolved display path', async () => { + const realDirectory = join(dir, 'real-workspace') + const linkedDirectory = join(dir, 'linked-workspace') + await mkdir(realDirectory) + await symlink(realDirectory, linkedDirectory, process.platform === 'win32' ? 'junction' : 'dir') + const target = await fs.resolve('linked-workspace/a.txt') + fs.internals.inspectTemp = async () => { await writeFile(join(realDirectory, 'a.txt'), 'competitor') } + + await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' })).rejects.toMatchObject({ + code: 'FS_NOT_OBSERVED', + message: `cannot overwrite existing "${join(linkedDirectory, 'a.txt')}" without reading it first`, + }) + expect(await readFile(join(realDirectory, 'a.txt'), 'utf8')).toBe('competitor') + }) + + it('createIfAbsent rejects a competing directory as not a regular file', async () => { + const path = join(dir, 'a.txt') + const target = await fs.resolve('a.txt') + fs.internals.inspectTemp = async () => { await mkdir(path) } + + await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + expect((await stat(path)).isDirectory()).toBe(true) + }) + + it('createIfAbsent rejects and preserves a dangling symbolic link', async () => { + const path = join(dir, 'dangling') + await symlink(join(dir, 'missing-target'), path) + const target = await fs.resolve('dangling') + + await expect(fs.writeText(target, 'ours', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('replaceIfVersion replaces when the version matches', async () => { await writeFile(join(dir, 'a.txt'), 'old') const target = await fs.resolve('a.txt') diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 9b62caba3f..13b0edc290 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -496,17 +496,77 @@ describe('writeFileAtomic — temp-file safety', () => { expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) }) - it('surfaces a non-collision guarded-create publication failure and cleans staging', async () => { + it('maps a non-collision guarded-create publication failure and cleans staging', async () => { const file = join(dir, 'a.txt') const denied = Object.assign(new Error('link denied'), { code: 'EACCES' }) await expect(writeFileAtomic(file, 'ours', undefined, undefined, { linkFile: async () => { throw denied }, - }, true)).rejects.toBe(denied) + }, { displayPath: file })).rejects.toMatchObject({ code: 'FS_IO_ERROR', cause: denied }) await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) }) + it('maps a guarded-create target-inspection failure and cleans staging', async () => { + const file = join(dir, 'a.txt') + const linkFailure = Object.assign(new Error('link failed'), { code: 'EIO' }) + const inspectionFailure = Object.assign(new Error('inspection denied'), { code: 'EACCES' }) + + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + linkFile: async () => { throw linkFailure }, + inspectPublicationTarget: async () => { throw inspectionFailure }, + }, { displayPath: file })).rejects.toMatchObject({ code: 'FS_IO_ERROR', cause: inspectionFailure }) + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) + }) + + it('rejects a guarded-create collision that vanishes before inspection', async () => { + const file = join(dir, 'a.txt') + const collision = Object.assign(new Error('target existed'), { code: 'EEXIST' }) + + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + linkFile: async () => { throw collision }, + }, { displayPath: file })).rejects.toMatchObject({ + code: 'FS_NOT_OBSERVED', + message: `cannot overwrite existing "${file}" without reading it first`, + cause: collision, + }) + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([]) + }) + + it('uses the display path and target type when guarded publication finds a competitor', async () => { + const file = join(dir, 'a.txt') + const displayPath = join(dir, 'linked-workspace', 'a.txt') + + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + inspectTemp: async () => { await writeFile(file, 'competitor') }, + }, { displayPath })).rejects.toMatchObject({ + code: 'FS_NOT_OBSERVED', + message: `cannot overwrite existing "${displayPath}" without reading it first`, + }) + expect(await readFile(file, 'utf8')).toBe('competitor') + + await rm(file) + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + inspectTemp: async () => { await mkdir(file) }, + }, { displayPath })).rejects.toMatchObject({ + code: 'FS_NOT_REGULAR_FILE', + message: `cannot write "${displayPath}": not a regular file`, + }) + expect((await stat(file)).isDirectory()).toBe(true) + }) + + it('does not turn post-commit staging cleanup failure into a failed guarded write', async () => { + const file = join(dir, 'a.txt') + const cleanupFailure = new Error('staging cleanup failed') + + await expect(writeFileAtomic(file, 'ours', undefined, undefined, { + removeStagingDir: async () => { throw cleanupFailure }, + }, { displayPath: file })).resolves.toBeUndefined() + expect(await readFile(file, 'utf8')).toBe('ours') + }) + it.skipIf(!posixModes)('creates new files owner-only by default', async () => { const file = join(dir, 'a.txt') await writeFileAtomic(file, 'hello', undefined, undefined) From 39d8678a02138cb27e0b9cc36b6670428dc2ee12 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:40:56 +0800 Subject: [PATCH 03/32] docs(fs): enumerate absence observation paths --- docs/subsystems/filesystem.i18n.yaml | 4 ++-- docs/subsystems/filesystem.md | 2 +- docs/subsystems/filesystem.zh.md | 2 +- docs/tool-catalog.i18n.yaml | 4 ++-- docs/tool-catalog.md | 2 +- docs/tool-catalog.zh.md | 2 +- packages/fs/fs-policy/README.i18n.yaml | 4 ++-- packages/fs/fs-policy/README.md | 2 +- packages/fs/fs-policy/README.zh.md | 2 +- packages/fs/fs-sandbox/src/index.ts | 4 ++-- packages/fs/tool-str-replace-editor/README.i18n.yaml | 4 ++-- packages/fs/tool-str-replace-editor/README.md | 2 +- packages/fs/tool-str-replace-editor/README.zh.md | 2 +- scripts/gen-tool-catalog.ts | 2 +- 14 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 04c08db9a6..f7d59cd66d 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.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/filesystem.md -filesystem.md: 5d06611cbad2a3f146ddef4dbc96b2b6fc0a5b46 -filesystem.zh.md: 97aac2ee20783787909605d90a94181bf093d78a +filesystem.md: dd81cc27ad73bab026cd11da6ab184890bb6535d +filesystem.zh.md: 9589adfaf43c1d5a4bbc8571e70c878770d880b2 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index 5d06611cba..dd81cc27ad 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -238,7 +238,7 @@ interface FileReadOutcome { ## Observed-file state (policy plugin) -Observed state is a `WeakMap>` held inside the `dsh-fs-policy` plugin. Missing map entry means unseen; `{ kind: 'absent' }` means a tool read/view confirmed absence; `{ kind: 'present', version }` means a read, write, or edit observed that version. The write decision maps unseen and absent to `createIfAbsent`, while present maps to `replaceIfVersion`; the edit decision maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. Disposal drops everything (HMR safety), and the policy performs no filesystem I/O. +Observed state is a `WeakMap>` held inside the `dsh-fs-policy` plugin. Missing map entry means unseen; `{ kind: 'absent' }` means a `read` or `str_replace_editor` `view`, `str_replace`, or `insert` metadata miss confirmed absence; `{ kind: 'present', version }` means a read, write, or edit observed that version. The write decision maps unseen and absent to `createIfAbsent`, while present maps to `replaceIfVersion`; the edit decision maps unseen to `FS_NOT_OBSERVED`, absent to `FS_NOT_FOUND`, and present to its version guard. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. Disposal drops everything (HMR safety), and the policy performs no filesystem I/O. ## Error taxonomy (provider contract) diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 97aac2ee20..9589adfaf4 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -238,7 +238,7 @@ interface FileReadOutcome { ## 已观测文件状态(策略插件) -已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap>`。映射中没有条目表示未见;`{ kind: 'absent' }` 表示工具的 read/view 已确认缺失;`{ kind: 'present', version }` 表示 read、write 或 edit 观测到该版本。写入决策把未见和缺失映射到 `createIfAbsent`,把存在映射到 `replaceIfVersion`;编辑决策把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。dispose(资源释放)时丢弃全部数据(HMR(热模块替换)安全),策略不执行任何文件系统 I/O。 +已观测状态是 `dsh-fs-policy` 插件内部持有的 `WeakMap>`。映射中没有条目表示未见;`{ kind: 'absent' }` 表示 `read` 的元数据未命中,或 `str_replace_editor` 的 `view`、`str_replace`、`insert` 命令发生元数据未命中,从而确认缺失;`{ kind: 'present', version }` 表示 read、write 或 edit 观测到该版本。写入决策把未见和缺失映射到 `createIfAbsent`,把存在映射到 `replaceIfVersion`;编辑决策把未见映射到 `FS_NOT_OBSERVED`,把缺失映射到 `FS_NOT_FOUND`,把存在映射到其版本守卫。所有者从事件 actor 推导(通常是 `exec.agent.session`),被视为不透明且从不读取。dispose(资源释放)时丢弃全部数据(HMR(热模块替换)安全),策略不执行任何文件系统 I/O。 ## 错误分类体系(提供方约定) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 752a8f6a9e..1ae1b619f3 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-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/tool-catalog.md -tool-catalog.md: abcba1ca5dcb148aa0ee5578ff4fd0373993db1d -tool-catalog.zh.md: 32c761d0d2854bf2310eb0ddb382553575bd4df9 +tool-catalog.md: f6a41da266db6eb2f347fb3c455a67b4dd44c4c0 +tool-catalog.zh.md: b36e8ccf63f4bdde7efca050bd92cbfcb03b3fbb diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index abcba1ca5d..f6a41da266 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,7 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | -| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | +| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful mutation`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 32c761d0d2..b36e8ccf63 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,7 +24,7 @@ | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.bash`、`ctx.systemPrompt`、`ctx.bashEnv`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.bash` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.tasks` 运行时,并通过 `task_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-bash-env`。每次调用都在新进程中运行,不使用持久 PTY 会话;ConPTY 尚在规划中。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`、`cordis_mount`、`cordis_unmount` | `ctx.tools` | `tool/call`、`tool/result`、`process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.pty`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | -| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | +| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | | `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful mutation`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件,上述工具 schema 都完全相同。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.tools`、`ctx.pty`、`ctx.systemPrompt`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.tasks`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 | diff --git a/packages/fs/fs-policy/README.i18n.yaml b/packages/fs/fs-policy/README.i18n.yaml index e316171061..65239a6f00 100644 --- a/packages/fs/fs-policy/README.i18n.yaml +++ b/packages/fs/fs-policy/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/fs/fs-policy/README.md -README.md: 0166690c66f38efe817d8b5779db9a677d22d321 -README.zh.md: 26e0199e6deb7fc12a860ab01884b70c1b7c9b38 +README.md: 395a36e89e113dc3846a8dff62ce90601013addd +README.zh.md: 5b3b1f64de2d51f6b729392da2d4b459a9f0ca3b diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index 0166690c66..395a36e89e 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -39,7 +39,7 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek ## Observed state is the prior-observation record; freshness is provider CAS -Observed state is a weak owner-to-target map with three logical states: unseen, confirmed absent, or present at a version. A successful file read or mutation records presence; a `read`/`view` metadata miss records absence before returning `FS_NOT_FOUND`. The plugin performs no filesystem I/O: it converts that state into a provider guard. Presence supplies the observed version, while absence lets only a `createIfAbsent` write proceed; edit has no version basis and returns `FS_NOT_FOUND`. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions. +Observed state is a weak owner-to-target map with three logical states: unseen, confirmed absent, or present at a version. A successful file read or mutation records presence; a metadata miss from `read` or the `str_replace_editor` `view`, `str_replace`, or `insert` command records absence before returning `FS_NOT_FOUND`. The plugin performs no filesystem I/O: it converts that state into a provider guard. Presence supplies the observed version, while absence lets only a `createIfAbsent` write proceed; edit has no version basis and returns `FS_NOT_FOUND`. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions. ## Single-slot, first-wins diff --git a/packages/fs/fs-policy/README.zh.md b/packages/fs/fs-policy/README.zh.md index 26e0199e6d..5b3b1f64de 100644 --- a/packages/fs/fs-policy/README.zh.md +++ b/packages/fs/fs-policy/README.zh.md @@ -39,7 +39,7 @@ await ctx.plugin(FsPolicy) ## 已观察状态是先前观察记录;新鲜度由提供方 CAS 保证 -观测状态是一张以所有者为弱键、记录各目标的映射表,具有三种逻辑状态:未见、确认缺失、存在于某个版本。成功读取文件或变更会记录存在;`read`/`view` 的元数据未命中会在返回 `FS_NOT_FOUND` 前记录缺失。插件不执行文件系统 I/O:它把该状态转换为提供方防护。存在状态提供观测到的版本;缺失状态只允许 `createIfAbsent` 写入继续,edit 因没有版本基准而返回 `FS_NOT_FOUND`。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。 +观测状态是一张以所有者为弱键、记录各目标的映射表,具有三种逻辑状态:未见、确认缺失、存在于某个版本。成功读取文件或变更会记录存在;`read` 的元数据未命中,或 `str_replace_editor` 的 `view`、`str_replace`、`insert` 命令发生元数据未命中时,都会在返回 `FS_NOT_FOUND` 前记录缺失。插件不执行文件系统 I/O:它把该状态转换为提供方防护。存在状态提供观测到的版本;缺失状态只允许 `createIfAbsent` 写入继续,edit 因没有版本基准而返回 `FS_NOT_FOUND`。窗口读取会观察整个文件的版本,因此只有文件保持不变时才允许后续的定向编辑。插件 dispose(资源释放)时会丢弃状态,并且不会跨会话持久化。 ## 单槽、先到者胜 diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 1d5f972da9..9884cc5fbd 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -76,7 +76,7 @@ export class SandboxedFileSystem extends LocalFileSystem { * @param target - the resolved target to write. * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. - * @param signal - aborts before the atomic rename takes effect. + * @param signal - aborts before atomic publication takes effect. * @param sandboxPolicy - the per-call mode and workspace root; omit to use * the deployment fallback. * @returns the write outcome from the inherited backend. @@ -97,7 +97,7 @@ export class SandboxedFileSystem extends LocalFileSystem { * @param target - the resolved target to edit. * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. - * @param signal - aborts before the atomic rename takes effect. + * @param signal - aborts before atomic publication takes effect. * @param sandboxPolicy - the per-call mode and workspace root; omit to use * the deployment fallback. * @returns the edit outcome from the inherited backend. diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index fd61c07154..d5f4b31574 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/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/fs/tool-str-replace-editor/README.md -README.md: 1d6ce6fd801997dd21973fde1ffded84dbee1b5a -README.zh.md: 62728659fa8c8173ac42a42610f1a710aec6e386 +README.md: 8b4772cc4eb40e23a5d6ea8e409188b5033318ba +README.zh.md: b9ccf285870f2d02a2195b70dcd24fd37794976e diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 1d6ce6fd80..8b4772cc4e 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -13,7 +13,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w ## Tool -The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. A missing view records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. +The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. A metadata miss from `view`, `str_replace`, or `insert` records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. ## Model Experience diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index 62728659fa..b9ccf28587 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -13,7 +13,7 @@ ## 工具 -schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。查看缺失目标时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 +schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。`view`、`str_replace` 或 `insert` 发生元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 ## 模型体验 diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index cd1cb3c0c1..1ed6c37f40 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -253,7 +253,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ dir: 'tool-str-replace-editor', source: 'packages/fs/tool-str-replace-editor/src/index.ts', requires: ['ctx.tools', 'ctx.fs'], - writes: ['tool/call', 'fs/observed after view presence/absence or successful mutation', 'tool/result'], + writes: ['tool/call', 'fs/observed after view presence/absence, edit absence, or successful mutation', 'tool/result'], async mount(ctx) { await ctx.plugin(LocalFileSystem) await ctx.plugin(ToolStrReplaceEditor) From dc825be8d874df216d3fcc50bac36149c06568e6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:05:12 +0800 Subject: [PATCH 04/32] docs: design client conversation node assembly --- ...lient-conversation-node-assembly.i18n.yaml | 6 + ...08-09-client-conversation-node-assembly.md | 407 ++++++++++++++++++ ...09-client-conversation-node-assembly.zh.md | 407 ++++++++++++++++++ 3 files changed, 820 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md create mode 100644 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml new file mode 100644 index 0000000000..953a049744 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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/architecture/2026-08-09-client-conversation-node-assembly.md +2026-08-09-client-conversation-node-assembly.md: 0cee5449f651c568dbb87b9cb867eb77b6380184 +2026-08-09-client-conversation-node-assembly.zh.md: 146e8f68a9b1339934040cee62fd46e981145f2a diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md new file mode 100644 index 0000000000..0cee5449f6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -0,0 +1,407 @@ +# Agent Note: Client Conversation business-node assembly and keyed Chat snapshots + +Status: implemented + +English | [中文](2026-08-09-client-conversation-node-assembly.zh.md) + +## Problem + +Client Session owned transport windows, connection state, and pending interactions while also interpreting Assistant, Tool, message, command, compaction, retry, and turn-tail events in a centralized transcript fold. Adding one business node required changes to Session switches, history replay, indexes, caches, and React grouping; business identity, state evolution, and final presentation had no independent owner. + +The old path also placed running Assistant and Tool values outside the finalized flow. They entered the log-ordered node list only after settlement, so their React parent changed and remounted them even when the business ID and `key` remained stable. Full history loads, older prepends, live appends, and token streaming used separate update paths, leaving reference stability and local recomputation dependent on specialized caches spread across the client. + +Business events also use different correlation models. Tool has call IDs, Assistant correlates by turn and step, Compaction has its own lifecycle and checkpoint, and an Inbox splice represents one instantaneous state in a sequence. Keeping all these distinctions in one fold would make every business change pass through a global lookup and invalidate unrelated caches. + +## Decision + +Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous Event window to the engine and publishes its snapshot instead of interpreting individual conversation businesses. + +The complete derivation, business-by-business validation, and file-level implementation plan remain in [`business-node assembler version one`](../../../../docs/client-conversation-node-engine-rfc.md), [`follow-up design differences`](../../../../docs/client-conversation-node-engine-follow-up-differences.md), [`business-node and dual-view adaptation analysis`](../../../../docs/client-conversation-node-adaptation-analysis.md), and the [`Chat implementation design`](../../../../docs/client-conversation-node-chat-implementation-plan.md). Those design documents retain the full discussion; this Note fixes the responsibilities, algorithms, and trade-offs that remain relevant after implementation. + +### Responsibility layers + +| Layer | Durable responsibility | Explicitly does not own | +|---|---|---| +| Session | Maintain the contiguous Event window, distinguish replace, prepend, and append, and schedule snapshot notifications | Interpret Tool, Assistant, Compaction, or other business events | +| Event Registry | Retain the unique-`kind` Definitions and sole fallback under Cordis lifecycles | Store one Session's Context or State | +| Assembler | Match Events and maintain Contexts, Locations, dependencies, and the publication dirty set | Interpret business State fields or Chat ordering | +| Node Definition | Define one business object's identity, State transitions, Location data, and target Node | Create Contexts, mutate another business's State, or scan all Contexts | +| View Builder | Incrementally organize final target Nodes into that view's snapshot | Reinterpret raw Session Events | +| React renderer | Render renderer-owned data by the final Node's `kind` and read business data from the current Node's Location | Pair business Events, scan global Nodes, or decide business lifecycle state | + +Registry contributions are Cordis effects. Removing a Definition causes a low-frequency registry rebuild for existing Sessions; ordinary business Events do not change the Registry or rebuild every business type. + +### Overall `ConversationNodeDefinition` contract + +Each [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs. + +One Event may be claimed by several ordinary Definitions. For example, an Assistant Event updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Error. The Assembler asks the fallback only when every ordinary Definition returns `null`. + +A Definition holds no mutable business data across Sessions. Each Session's Assembler isolates that Session's Contexts, State, dependencies, and View Builders. + +#### `kind`, business ID, and Context key + +The `id` returned by `match()` only needs to be stable within its Definition. A Tool ID can be a call ID, an Assistant ID can be `turn:step`, and an Inbox ID can be the splice Event seq. + +The Assembler uses `conversationContextKey(kind, id)` to make a collision-free key. Definitions that return the same `id` still do not share a Context. The final view Node must retain this engine-owned key and cannot use `seq` or render position as identity. + +Each `(kind, id)` has at most one start Match. A second start fails immediately; a Definition must return a new ID to represent a new lifecycle. + +#### `match(event)` + +`match(event)` reads only the current raw `SessionEvent` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope. + +This restriction makes one Event's routing cost depend only on the number of registered Definitions. The Assembler never scans a Definition's historical Contexts to decide which one owns an update. + +Start, result, resource, checkpoint, and business-owned terminal Events must carry or directly imply the same ID. If one Event cannot yield that ID, its producer extends the Event protocol; the Client does not guess from the "nearest unfinished object." + +The `role` describes the State lifecycle, not visibility. A start may produce a terminal Node immediately, while an update may enter a pending Context before its start has loaded. + +#### `ConversationMatch` + +After a successful match, the Assembler combines the raw Event, optional wire presentation view, `role`, and engine-computed `location` into a read-only `ConversationMatch`. + +A Context's `matches` always remain in ascending Event `seq` order, not network arrival or pagination ingestion order. If a tail page supplies a result before an older page supplies its call, the final Match order still places the call before the result. + +Location can change when prepend fills a boundary or append closes one. The Assembler replaces the affected Matches' read-only Locations and replays the Context; business code does not retain an old Location copy as authority. + +#### `ConversationNodeContext` + +| Field | Owner | Semantics visible to the Definition | +|---|---|---| +| `key` | Assembler | Stable final identity derived from `kind + id` | +| `kind` / `id` | Definition + Assembler | Current business namespace and business ID | +| `matches` | Assembler | Complete business evidence loaded in the current window and sorted by `seq` | +| `start` | Assembler | Unique start Match, or `undefined` before it loads | +| `state` | Returned by Definition, held by Assembler | Most recent `start`/`update` return value, or `undefined` before initialization | +| `current` | Assembler | Most recently materialized Node or `null` for each target | + +Read-only Context fields do not require deeply immutable business State. A Definition may return a new object or mutate the old object in place and return the same reference. + +The Assembler adopts only the returned value. Returning `undefined` from `start()` or `update()` is a contract error and fails immediately; mutating an object without returning it is likewise invalid. + +A Definition may inspect all `matches` to help construct State or a fallback Node, but it cannot add or remove Matches, replace Context fields, or mutate another Context. + +#### `start(context, match, reader)` + +`start()` is the sole State initialization entry point. The Assembler invokes it when the unique start first appears and adopts its returned State. + +When an older page changes Match order, the Reader's predecessor answer, or Location facts, the Assembler recomputes from `start()` instead of applying a reverse-direction patch to old State. + +The Context may already contain updates after the start when `start()` runs. After `start()` returns initial State, the Assembler still invokes `update()` for every post-start Match in ascending log order, so ingestion direction cannot change the final fold. + +The `reader` is available only in `start()`. Initialization can read the nearest active Context of a specified `kind` strictly before the current start seq, but business code receives no general interface for scanning internal engine Maps. + +Each new `start()` invocation replaces the Reader dependencies recorded by the prior invocation, so a Definition that changes its query branch retains no stale edges. + +#### `reader.previous(kind)` + +`reader.previous(kind)` finds the nearest Context whose `candidate.startSeq < current.startSeq` and whose State is initialized. It never returns a Context at the same seq, a future Context, or a pending Context without State. + +The result contains the predecessor's key, kind, ID, start seq, read-only State, and Matches. The consumer interprets that State itself; the provider only maintains its State correctly and need not register a specialized query method. + +Each Reader query records a `{ key, revision, windowGap }` dependency. A matched predecessor's revision change replays the consumer; a miss while older history remains records a window gap for a later prepend. + +When the window already reaches the Session beginning, a miss is a definitive `undefined`. When `hasMore` is true, the Definition sees the same `undefined`, but the Assembler remembers that the result is provisional. + +Dependencies point strictly from earlier starts to later starts, so transitive replay cannot form a temporal cycle. Both the Inbox instantaneous-state chain and Message reads of Inbox use this constraint. + +#### `update(context, match)` + +`update()` handles a post-start Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the Event. + +The Assembler invokes `update()` in ascending `seq` order. A live tail update can apply incrementally; any non-tail insertion, newly loaded start, or invalidated dependency causes a complete replay from `start()`. + +When no business data changes, `update()` returns the existing State. When data changes, it may return an immutable replacement or mutate the existing object and return that object. + +The Assembler does not use State reference equality to decide publication or propagation. Every accepted update increments the Context revision, marks it dirty, and causes direct or transitive Reader consumers to be reevaluated. + +#### `publication(match)` + +`publication()` controls when the latest State materializes as a view Node; it does not delay the synchronous execution of `match()`, `start()`, or `update()`. + +| Return value | Behavior | +|---|---| +| `immediate` | Request a notification and flush in the current microtask | +| `animation-frame` | Coalesce high-frequency updates into materialization on the next frame | +| `none` | Do not schedule a flush for this Match; retain its State and dirty marker | + +Omitting `publication()` means `immediate`. Assistant token deltas use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path. + +Every delta within a frame still executes update. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no tokens are lost. + +#### `buildLocationData(context, scope)` + +`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready. + +A Definition receives the `step` and `turn` scopes separately and may return one value or `null` in either phase. A value must identify the exact turn/step coordinates and use the Definition's `kind` as its key. The Assembler owns replacement and removal and rejects another Context that claims the same Location key. + +`ConversationStepDataMap` and `ConversationTurnDataMap` use declaration merging to constrain keys and values. A Location exposes only a stable `data.get(key)` reader; consumers cannot obtain the provider Context or mutate its State. + +#### `buildViewNode(context, target)` + +`buildViewNode()` reads the latest Context during publication and directly produces the final business Node for the named target. The Assembler adds no generic activity, tail-candidate, or layout business layer afterward. + +`null` means this Context has not yet materialized for the target. On the ordinary incremental path, a Context that has returned a non-null Node cannot later return `null`; temporary absence retains the same-key Node and uses the target's visibility representation. + +The Assembler verifies `node.key === context.key` and `node.target === target`. Business code may change `anchorSeq`, data, Location, or visibility, but cannot change identity within one lifecycle. + +`current` lets a Definition distinguish "never materialized" from "already materialized and now hidden." Assistant retry and Turn Error suppression use it to avoid illegal Node withdrawal. + +A Definition may branch by target to construct different data, while matching, Context identity, and State remain target-neutral. This change registers only the `chat` builder; Trajectory continues to consume the compatibility slice. + +#### No generic `end()` + +The engine exposes no fixed `end()` lifecycle. A single-Event business completes in `start()`, a multi-Event business records completion in its own update, and a long-lived instantaneous-state business creates a new Context for every Event. + +Step and Turn closure are external Location facts and do not mutate business State. A boundary change replays and builds affected Contexts; each business combines its own completion State with whether its Location is closed to produce normal, running, or interrupted presentation. + +IDs are never reused. Completed Contexts remain in the current window, providing stable render identity and possible predecessor evidence for later Readers. + +### Location is a first-class engine fact + +[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`. + +Location has four shapes: `session`, `turn`, `step`, and `unresolved`. Turns and Steps each carry `open`, `closed`, or `unknown` status plus any loaded start and end Events. + +Each Turn and Step also carries a reference-stable Location data store. A Definition update replaces only its owned key; the same store identity can acquire new values through append or prepend, allowing Contexts, View Builders, and React renderers to share resolved hierarchy-level business facts without copying or scanning the global Node array. + +`unresolved` means the current history window lacks sufficient preceding boundaries; it does not mean session-level. When older prepend supplies those boundaries, the index corrects Match Locations and replays only Contexts that own those seqs. + +An appended ordinary Event only inherits current coordinates, while an appended boundary recalculates only its owning Turn. Prepend rebuilds Location facts from the expanded contiguous window, but reference-stability logic retains unchanged Turn and Step objects. + +The Assembler also passes a reference-stable timeline to each View Builder. Businesses do not separately maintain turn order, step lists, last-step values, or boundary Maps. + +## Three Event-window paths + +"Backward history scanning" describes the UI loading pages from the newest tail toward the Session beginning; it does not mean a Definition executes `update()` in reverse. Regardless of history API order or page-loading direction, the Assembler canonicalizes each current window and each fresh page in ascending `seq` order. + +| Scenario | Input range | Context and State handling | View Builder | +|---|---|---|---| +| Initial history tail or resync | Current complete contiguous window | Clear and rebuild all Contexts in ascending `seq` order | `replace()` | +| Load one older-history page | Only deduplicated fresh Events before the window | Retain existing Context identity, then add Matches, Locations, dependencies, and local replays | `apply(upserts)` | +| Live append | One contiguous tail Event | Match Definitions and update only the exact IDs; boundaries affect only their owning Turn | `apply(upserts)` | + +### Initial history tail and logical backward scanning + +1. `Session.open()` loads the latest tail page and passes its contiguous History Entries to `replaceWindow(entries, hasMore)`. +2. `replaceWindow` clears old Contexts, start-seq indexes, seq reverse indexes, Reader dependencies, and the input Map. +3. It sorts every entry by Event `seq` and stores the resulting current window. +4. LocationIndex rebuilds Turn and Step facts for that window. +5. The Assembler visits Events in ascending order and invokes every ordinary Definition's `match(event)`. +6. Each result gets or creates its `(kind, id)` Context and enters that Context's ordered Match array. +7. A start runs `start()`; a tail update on initialized State runs `update()` directly. +8. If the page contains only a result or resource and omits its start, the ID still creates a Context and collects Matches, while State remains `undefined`. +9. After matching all Events, the Assembler rechecks Reader dependencies so earlier instantaneous states in the same window stabilize before later consumers read them. +10. Every Context becomes dirty, and the next flush fully rebuilds Location data in Step→Turn order before invoking `buildViewNode()` for every target. +11. Some businesses return `null` without a start; Compaction, Command, Tool result, and Turn Error can construct fallback Nodes from sufficient update evidence. +12. Each View Builder receives the complete Node set and timeline and establishes the initial snapshot through `replace()`. + +This path starts from the newest page only at the pagination layer. State within the page always computes forward, so the same window does not produce different business results under a different scan direction. + +A Context without a start is not an error. It is a pending aggregation container waiting for an older page; that Definition's `buildViewNode()` decides whether the evidence already makes it visible. + +If an update with the same ID is genuinely earlier than the start in log order, rather than merely loaded first, replay fails with a protocol error after the start arrives. Arrival order may be reversed; business log order may not. + +### Prepending a newly loaded older page + +1. `Session.loadOlder()` requests the immediately preceding page using the current `baseSeq` and first verifies continuity between the page tail and current window. +2. Session prepends the raw Event and view arrays to its own window and passes only that page to `assembler.prepend(entries, hasMore)`. +3. The Assembler removes seqs that overlap the current window, then sorts the fresh page internally in ascending order. +4. Existing Contexts, State, current Nodes, and View Builder instances remain intact. +5. LocationIndex rebuilds facts over the expanded complete input and reports seqs whose Location identity actually changed. +6. Contexts owning those seqs update their Match Locations and replay from start; unrelated Contexts do not join Location replay. +7. Fresh Events run Definition matchers and enter existing or new Contexts by stable ID. +8. If the new page supplies a pending Context's start, that Context initializes from the start and then applies every already-collected update in ascending order. +9. If the page establishes a nearer Reader predecessor, changes a predecessor revision, or removes a window gap, the consumer recomputes from `start()`. +10. Reader dependencies propagate replay toward later start seqs; no Event is applied in reverse within the propagation batch. +11. An empty page that changes `hasMore` from true to false also rechecks dependencies and resolves a provisional `undefined` to definitive absence. +12. The flush republishes Step/Turn Location data and target Nodes only for dirty Contexts, then passes non-null results to View Builder `apply()` as `upserts`. + +Prepend retains existing Context keys and current Node identity. A page may add historical keys at the front of Chat `order` or correct an existing Node's anchor, Location, visibility, or data, but it does not recreate unrelated business Contexts. + +On a structural change, the Chat Builder recomputes visible `order` and the secondary Location index from its keyed store. That is view-index work; it neither reruns every business Definition nor replaces unchanged Node values. + +Reader gap repair is the largest algorithmic difference between prepend and ordinary append. A page can both add visible historical Nodes and change later Inbox instantaneous states and the Message classifications that depend on them. + +### Forward live append + +1. Session accepts only a live Event immediately after the current tail seq; it deduplicates overlap and runs tail-page repair before accepting a gap. +2. A non-boundary Event enters the current Turn and Step coordinates incrementally; a boundary Event updates Location facts for its owning Turn. +3. The Assembler invokes `match()` once on every ordinary Definition for this Event and scans no Definition's Context set. +4. Each successful result directly locates one Context through `(kind, id)`. +5. A new ID creates a Context; a normal tail update for an existing ID invokes `update()` once. +6. A start or any evidence inserted before the tail uses complete `replayContext()` and retains the same forward-order semantics. +7. After a Context revision changes, only recorded Reader dependents replay. +8. Location close updates affected Matches within its owning Turn and replays those Contexts, allowing unfinished Assistant, Tool, or Retry values to acquire interrupted or cancelled presentation. +9. The Assembler takes the highest publication urgency among all matching Definitions: `immediate` outranks `animation-frame`, which outranks `none`. +10. Session routes immediate work to the microtask notifier and animation-frame work to the RAF notifier. +11. The flush updates Step/Turn Location data for dirty Contexts, then invokes `buildViewNode()` and passes this transaction's upserts and latest timeline to each View Builder. +12. The new React snapshot reuses stable Context keys; the same Tool running→settled or Assistant streaming→final value never moves across parents. + +Append's business-matching cost is the Definition count plus the Contexts actually updated, independent of historical Context count. Reader consumers and Location closure add replay proportional to real dependencies or the owning Turn. + +A structural Chat `order` change can still reorder the current visible keys. A data-only update replaces one keyed-store Node and touches its Location index. The guarantee is that unrelated businesses do not refold and unchanged Node identity is retained, not that every view-index operation has constant complexity. + +### Consistency across replace, prepend, and append + +All three paths preserve the same invariants: Context Matches are seq-ordered, State folds forward from one unique start, Reader sees only strictly preceding active Contexts, Location data publishes in Step→Turn order, and Node key depends only on kind and ID. + +`replaceWindow` is the low-frequency complete replacement for initial open, resync, gap repair, and registry changes; it does not implement ordinary load older. Both `prepend` and `append` retain existing Builder and Context identity. + +Page size, the number of history loads, and RAF coalescing affect only when evidence arrives or publishes. They do not change final Context State and Nodes for an equal Event window. + +## How built-in businesses use Definitions + +### Matching, ID, and State + +| Business / `kind` | Stable ID | Start Match | Update Matches | State and cross-Context reads | +|---|---|---|---|---| +| Next-turn Inbox / `inbox-next-turn` | Splice Event seq | Each `agent/inbox/spliced` targeting next-turn | None | Apply the current splice to the pending/claimed instantaneous state from `reader.previous(ownKind)` | +| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Build the same per-instruction instantaneous state; Message reads its claimed set | +| Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering | +| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data | +| Tool / `tool-call` | Root call ID | Root `tool/call` | Root result and Code Dispatch start/result | Aggregate the root, children, and parent Map; Dispatch Events route exactly through `rootCallId` | +| Command / `command` | Command ID | `command/run` | `command/done` and compact lifecycle/checkpoint Events carrying a source command ID | Aggregate command outcome and manual-compaction evidence | +| Automatic Compaction / `compaction` | Compaction ID | `compact/start` without a source command ID | Summary, end, and replacement checkpoint | Aggregate summary/checkpoint; sufficient checkpoint evidence supports fallback without a start | +| Retry / `model-retry` | Retry ID | Attempt 1 `llm/retry` | Later `llm/retry` and `llm/retry-started` | Aggregate one RetryId's attempts and scheduled/started state | +| Turn Error / `turn-error` | Turn number | `turn/start` | Error `turn/end` and Retry Events for that Turn | Aggregate terminal failure and use Retry evidence to decide hiding | +| Turn Tail / `turn-tail` | Turn number | `turn/start` | Assistant, Retry, `step/end`, and `turn/end` | Retain turn end, read each Step's Assistant data, and publish Turn data; use complete Matches to choose the visual tail anchor | +| Deliverables / `deliverables` | Turn number | `turn/start` | Tool calls/results in that Turn | Aggregate successful mutation paths and publish Turn data without producing a view Node | +| Unknown fallback / `unknown-surface` | Event seq | Append-surface Event unclaimed by any ordinary Definition | None | Retain raw type/data for the JSON fallback | + +### Chat Node and history/live behavior + +| Business | `publication()` | Chat output | History and runtime behavior | +|---|---|---|---| +| Inbox | `none` | No Node | Recompute instantaneous states along the Reader chain when prepend supplies earlier splices | +| Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key | +| Assistant | RAF for chunks, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Matches support fallback without `step/start`; Location close produces interruption presentation | +| Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key | +| Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key | +| Compaction | Immediate by default | `compaction` marker | A checkpoint may render before start; an older start triggers forward replay | +| Retry | Immediate by default | One `model-retry` Node containing all attempts | Multiple retries update one key; Location close presents the last scheduled attempt as cancelled | +| Turn Error | Immediate by default | Visible or hidden `turn-error` | Error end supports fallback without start; later Retry keeps the key and hides it | +| Turn Tail | Immediate only for `turn/end`; otherwise none | Independent `turn-tail` footer | Compute closing/metrics from Step Assistant data and use same-turn Matches to choose the anchor | +| Deliverables | Immediate by default | No Node | Tool settlement incrementally updates Turn data; the Turn Tail extension slot reads produced files | +| Fallback | Immediate by default | `unknown` JSON row | Covers only append-surface Events; an ordinary business that claimed but has not rendered an Event does not duplicate it | + +Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. + +Assistant, Turn Tail, and Turn Error demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node. + +Assistant, Turn Tail, and Deliverables demonstrate layered Location data composition. Assistant writes `assistant-step` data for each Step; Turn Tail derives `turn-tail` data from those Step values; Deliverables independently maintains `deliverables` data for the same Turn. Consumers read only declaration-merged keys, do not scan another business's Nodes, and cannot obtain the provider's Context State. + +Tool and Command demonstrate multi-Event aggregation: the producer supplies a shared ID, and the Context builds a tree or integrates Compaction internally instead of pushing pairing into the Chat Builder. + +Compaction and historical Tool results demonstrate business fallback without a start. The engine does not impose "no start means no rendering"; each Definition decides whether current Matches are sufficient. + +Retry demonstrates the State and Location split. Scheduled and started belong to Retry State, while Step and Turn closure belong to engine Location; `buildViewNode()` combines them into cancelled presentation. + +Unknown fallback demonstrates Registry ownership: it handles only append-surface Events unclaimed by every ordinary matcher, and does not create a duplicate Node merely because a claimed Context temporarily returns `null`. + +## View Builder and React identity + +[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches. + +The Assembler calls `replace({ nodes, timeline })` on low-frequency complete replacements and `apply({ upserts, timeline })` for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions. + +[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice temporarily consumed by Trajectory. + +Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value. + +For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys. + +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. + +[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins still live in `ui-conversation`, but this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher. + +The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes only the stable Node key as `hookContext`; the Slot renderer combines that key with `useSession` from the official standard props to construct `useTurnData(businessKey)`. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority. + +Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent paths. The latter continues to bind only registration-owned Observables. The former caches definitions by stable slot-inject-face identity and binds its factory and Hook per stable render occurrence. The selector inside `useTurnData()` returns only the current Node's `turn.data.get(key)`, so selector equality filters unrelated Session publications. + +The standard `useSession` remains available to every session-scoped slot renderer. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data. + +Assistant streaming to final and Tool running to settled update only one Seat's data and necessary ordering properties. They no longer move from a tail running container into finalized flow, so settlement does not reset component-local State. + +When business logic deliberately changes a materialized Node to hidden, it leaves visible order and remounts when visible again. This is explicit business withdrawal of presentation, distinct from the stable-Seat guarantee for running→settled. + +The concrete Tool renderer remains governed by the [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md). Tool Definition supplies recursive root/subcall data, and `ui-tool` dispatches concrete presentation by the Tool-name keyed slot. + +Trajectory has no independent registered target yet. It continues to consume the legacy slice incrementally derived by the Chat Builder, while Session no longer runs a second transcript fold; a future migration does not change the Event Definition, Context, Reader, or Location contracts. + +## Runtime and render path + +```text +Session Event window + -> ConversationNodeAssembler + -> Definition.match(event) -> (kind, id, start/update) + -> Context matches + State + Location + -> Definition.buildLocationData(step -> turn) + -> StepLocation.data / TurnLocation.data + -> Definition.buildViewNode(target = chat) + -> ChatSnapshotBuilder + -> order[] + keyed Node store + Location index + timeline + -> ChatView + -> ChatNodeSeat(key) + -> conversation.chat.node(entryKey = node.kind, hookContext = key) + -> slot-level useTurnData(businessKey) +``` + +## Verification + +Runtime tests pin Definition lifecycle registration, exact-ID append, update-before-start collection followed by forward replay after start, prepend identity, Reader window-gap repair, transitive dependencies, Location closure, Step→Turn data phase order, Location data replacement, publication cadence, illegal withdrawal, and per-target Builders. + +Conversation tests cover every built-in Definition, Assistant Step data, Turn Tail and Deliverables Turn data, Chat ordering and structural sharing, selector isolation, Assistant and Tool running-to-settled identity, nested Code Dispatch, steering, Compaction, Retry, interruption, load-older anchoring, and slot dispatch. + +Slot type/runtime tests pin required parent-provided common inject, the `hookContext` type, Hook isolation across Node contexts, stable factory/Hook identity, and the absence of business-renderer rerenders for unrelated Session publications. Existing entry-owned Observable Hook tests continue to pin the path that does not use a contextual factory. + +Assembled Web snapshots, GUI tests, and browser scenarios cover the real plugin graph. Browser evidence compares Assistant streaming→settled, Bash running→settled, and Code Mode root + nested subcalls against master layout. + +History-path tests cover complete replace, non-overlapping prepend, overlapping-seq deduplication, empty-page `hasMore` convergence, and live append. Equal Event windows ingested through different paths produce equal business State and final Nodes. + +## Alternatives considered + +**Keep the centralized Session transcript fold and extract only helpers.** Rejected: business identity, history replay, and cache invalidation would still belong to one closed switch; moving functions would not establish independent ownership. + +**Let React renderers scan Session Events.** Rejected: every view would duplicate matching and lifecycle State, React would become business authority, and paging and streaming would recompute unrelated component trees. + +**Pass global Nodes or Location indexes to every business renderer.** Rejected: business components would scan and infer their current Turn/Step, and their subscription scope would grow with the window. A Definition publishes aggregates onto an engine-owned Location, and a renderer reads only its own Node's Location data. + +**Call every Context of a Definition for each new Event.** Rejected: append cost would grow with history, and `update()` would combine matching with conversion. Context-free `match(event)` finds the ID first, after which only one Context updates. + +**Let a Definition matcher read Contexts or scan history.** Rejected: matching would depend on ingestion direction, result-first history pages could not determine ownership independently, and live append would regress to searching open objects. + +**Define a reverse State fold for backward history scanning.** Rejected: every business would maintain two inverse algorithms, and deletion, non-invertible aggregation, and cross-Context dependencies would be difficult to keep equivalent. Ordered Matches followed by forward replay from start preserve one business meaning. + +**Make Inbox a first-class engine concept or one window-wide Context.** Rejected: Inbox is ordinary business State and does not belong in the generic engine. Per-splice instantaneous State plus a strictly backward Reader supports prepend, append, and Message lookup together. + +**Register specialized query methods for cross-business reads.** Rejected: consumers would still depend on provider APIs, and each new relationship would expand a central interface. Reader exposes a named kind's read-only predecessor Context; the provider writes useful State and the consumer interprets it. + +**Let a Location-data consumer read the provider's Context State directly.** Rejected: the consumer would depend on another business's mutable internal shape and could not express which Turn/Step owns the value. Declaration-merged data maps expose only the provider-selected read-only value and engine-owned coordinates. + +**Add generic `end()`, prepared, or window-reset lifecycles.** Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation. + +**Register separate Event Definitions for Chat and Trajectory.** Rejected: identity, State, and Location are target-neutral. `buildViewNode(target)` and each Builder express view differences; Trajectory retains a compatibility slice until its actual migration. + +**Add a generic layout model above final business Nodes.** Rejected: activity, tail candidacy, and layout enums would centralize current Chat business semantics in the engine again. Final Nodes carry renderer-required data directly and share only identity, ordering, and Location facts. + +**Register the Turn-data Hook only on the Assistant renderer.** Rejected: current-Node Location access is a common capability of the `conversation.chat.node` slot, not one business renderer. The parent Chat entry registers common inject once, and every keyed renderer shares the same strongly typed contract. + +**Keep running Assistant or Tool values in an independent tail container.** Rejected: settlement would move them across React parents, and a stable business key could not prevent remount. One keyed order permits data and position changes without changing Seat identity. + +## Consequences + +A new business node can register its matcher, State transitions, optional Location data, final target Node, and renderer locally without changing Session's business switch. `ChatNodeDataMap` and the Location data maps let a business package merge strongly typed data into the contract; every related Event must still expose a stable ID derivable from that Event alone. + +Initial tail, older prepend, and live append share one set of Context invariants. Missing starts, Reader window gaps, unknown Locations, and high-frequency deltas are explicit engine states and require no direction-specific business cache. + +Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity. + +Separating State updates from publication cadence folds every Assistant delta while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State. + +Steps and Turns become stable homes for cross-business aggregates. Turn Tail and Deliverables no longer depend on renderers scanning global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates. + +The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definition authors must understand stable IDs, unique starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal. + +`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice until Trajectory migrates; built-in Definitions currently remain centralized in `ui-conversation`. These compatibility boundaries do not return business interpretation to Session. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md new file mode 100644 index 0000000000..146e8f68a9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -0,0 +1,407 @@ +# Agent Note: Client Conversation 业务节点组装与 Chat keyed snapshot + +Status: implemented + +[English](2026-08-09-client-conversation-node-assembly.md) | 中文 + +## Problem + +Client Session 既维护传输窗口、连接状态和待处理交互,也在中心化 transcript fold 中解释 Assistant、Tool、消息、命令、压缩、重试及 turn tail 等业务事件。每增加一种业务节点,都要修改 Session 的 switch、历史 replay、索引、缓存和 React 分组;业务 identity、状态演进与最终展示没有独立所有者。 + +旧链路还把运行中的 Assistant 和 Tool 放在 finalized flow 之外。它们结算后才进入按日志排序的节点列表,因此 React parent 会改变,即使业务 ID 和 `key` 不变也会重新挂载。全量历史加载、older prepend、实时 append 与 token streaming 又分别走不同更新路径,使引用稳定和局部重算只能靠各处特化缓存维持。 + +业务事件之间的关联方式并不统一。Tool 有 call ID,Assistant 以 turn/step 关联,Compaction 有独立生命周期和 checkpoint,Inbox splice 则表示一个连续状态的瞬间。把这些差异继续塞进统一 fold,会让任一业务变化都经过全局查表并使无关缓存失效。 + +## Decision + +Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builder;Session 只负责把当前连续事件窗口送入引擎并发布它的 snapshot,不再解释具体 conversation 业务。 + +详细的方案推导、逐业务适配和逐文件实施设计保留在 [`业务节点组装器第一版`](../../../../docs/client-conversation-node-engine-rfc.md)、[`后续方案差异`](../../../../docs/client-conversation-node-engine-follow-up-differences.md)、[`业务节点与双视图适配论证`](../../../../docs/client-conversation-node-adaptation-analysis.md) 和 [`Chat 链路工程实施设计`](../../../../docs/client-conversation-node-chat-implementation-plan.md)。这些设计稿保留完整讨论过程;本 Note 固定实现后仍需长期维护的职责、算法和取舍。 + +### 责任分层 + +| 层 | 长期职责 | 明确不负责 | +|---|---|---| +| Session | 维护连续 Event 窗口,区分 replace、prepend、append,调度 snapshot 通知 | 解释 Tool、Assistant、Compaction 等业务事件 | +| Event Registry | 按 Cordis 生命周期保存唯一 `kind` 的 Definition 和唯一 fallback | 保存某个 Session 的 Context 或 State | +| Assembler | 匹配 Event,维护 Context、Location、依赖和发布脏集 | 理解业务 State 字段或 Chat 排序 | +| Node Definition | 定义一个业务对象的 identity、State 演进、Location data 和 target Node | 创建 Context、修改别的业务 State 或扫描全部 Context | +| View Builder | 把最终 target Node 增量整理成该视图的 snapshot | 重新解释原始 Session Event | +| React renderer | 按最终 Node 的 `kind` 展示 renderer-owned data,并读取当前 Node 所属 Location 的只读业务 data | 配对业务 Event、扫描全局 Nodes 或决定业务生命周期 | + +Registry 注册是 Cordis effect,Definition 卸载会触发现有 Session 的低频 registry rebuild。普通业务 Event 不改变 Registry,也不会因此重建全部业务类型。 + +### `ConversationNodeDefinition` 总体契约 + +每个 [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。 + +同一个 Event 可以被多个普通 Definition 认领。例如一条 Assistant Event 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Error。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。 + +Definition 不持有跨 Session 的可变业务数据。每个 Session 的 Context、State、依赖和 View Builder 都由该 Session 的 Assembler 隔离持有。 + +#### `kind`、业务 ID 与 Context key + +`match()` 返回的 `id` 只要求在当前 Definition 内稳定。Tool 的 ID 可以是 call ID,Assistant 的 ID 可以是 `turn:step`,Inbox 的 ID 可以是 splice Event seq。 + +Assembler 使用 `conversationContextKey(kind, id)` 组合无碰撞 key;不同 Definition 即使返回相同 `id` 也不会共享 Context。最终 view Node 必须沿用这个 engine-owned key,不能把 `seq` 或渲染位置当 identity。 + +每个 `(kind, id)` 最多存在一个 start Match。第二个 start 会立即报错;Definition 需要表达新生命周期时必须返回新 ID。 + +#### `match(event)` + +`match(event)` 只读取当前原始 `SessionEvent`,返回 `{ id, role: 'start' | 'update' }` 或 `null`。它拿不到 Context、历史、Reader、Location 或 view envelope。 + +这项限制使单条 Event 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。 + +start、result、resource、checkpoint 及业务自有终止 Event 必须携带或可直接推导同一 ID。若单个 Event 不能算出 ID,生产 Event 的协议负责补足关联字段,Client 不通过“最近一个未完成对象”猜测。 + +`role` 描述 State 生命周期,不描述可见性。start 可以立即生成 terminal Node;update 也可以在 start 尚未加载时先进入 pending Context。 + +#### `ConversationMatch` + +匹配成功后,Assembler 把原始 Event、可选的 wire presentation view、`role` 和引擎计算的 `location` 组成只读 `ConversationMatch`。 + +Context 的 `matches` 永远按 Event `seq` 升序保存,而不是按网络到达或分页摄入顺序保存。历史尾页先出现 result、older 页后出现 call 时,最终 Match 顺序仍然是 call 在前、result 在后。 + +Location 可以随 prepend 补齐边界或 append 关闭边界而改变。Assembler 替换受影响 Match 的只读 Location 并 replay Context;业务不把旧 Location 副本当权威保存。 + +#### `ConversationNodeContext` + +| 字段 | 所有者 | Definition 可见语义 | +|---|---|---| +| `key` | Assembler | `kind + id` 的稳定最终 identity | +| `kind` / `id` | Definition + Assembler | 当前业务命名空间和业务 ID | +| `matches` | Assembler | 当前窗口已收集且按 `seq` 排序的完整业务证据 | +| `start` | Assembler | 唯一 start Match;尚未加载时为 `undefined` | +| `state` | Definition 返回、Assembler 持有 | 最近一次 `start`/`update` 返回值;未初始化时为 `undefined` | +| `current` | Assembler | 各 target 最近一次 materialize 的 Node 或 `null` | + +Context 字段只读,不表示业务 State 必须是深度 immutable。Definition 可以返回新对象,也可以原地修改旧对象后返回同一引用。 + +Assembler 只采纳函数返回值。`start()` 或 `update()` 返回 `undefined` 是契约错误并立即报错;修改了对象却不返回它同样不成立。 + +Definition 可以读取完整 `matches` 辅助构造 State 或 fallback Node,但不能增删 Match、替换 Context 字段或修改另一个 Context。 + +#### `start(context, match, reader)` + +`start()` 是 State 的唯一初始化入口。Assembler 首次得到唯一 start 后调用它,并采用其返回 State。 + +当更早分页改变 Context 的 Match 顺序、Reader 前序答案或 Location 事实时,Assembler 从 `start()` 重新计算,而不是对旧 State 做方向相反的补丁。 + +调用 `start()` 时,Context 可能已经收集 start 之后的 updates。`start()` 返回初始 State 后,Assembler 仍会从 start 之后按日志正序逐条调用 `update()`,因此摄入方向不会改变最终 fold 结果。 + +`reader` 只在 `start()` 中可用。它允许初始化逻辑读取严格位于当前 start seq 之前、指定 `kind` 的最近 active Context,但不给业务一个任意扫描引擎内部 Map 的接口。 + +每次重新调用 `start()` 都会替换上一次调用登记的 Reader 依赖,保证 Definition 改变查询分支时不会保留陈旧边。 + +#### `reader.previous(kind)` + +`reader.previous(kind)` 查找满足 `candidate.startSeq < current.startSeq` 且 State 已初始化的最近 Context。它不会返回同 seq、未来 Context 或尚无 State 的 pending Context。 + +返回值包含前序 Context 的 key、kind、id、start seq、只读 State 和 Matches。消费者自行解释 State;提供方只负责把自己的 State 维护正确,不需要注册特化 query 方法。 + +Reader 每次查询都记录 `{ key, revision, windowGap }` 依赖。命中前序 Context 时,其 revision 变化会 replay 消费者;未命中且仍有 older 历史时,window gap 会等待后续 prepend。 + +若窗口已经到达 Session 起点仍未命中,`undefined` 是确定答案。若 `hasMore` 为 true,Definition 看到的仍是同一个 `undefined`,但 Assembler 会记住这是暂定结果。 + +依赖严格从较早 start 指向较晚 start,因此传递 replay 不形成时序环。Inbox 瞬间态链和 Message 对 Inbox 的读取都使用这一约束。 + +#### `update(context, match)` + +`update()` 只处理已经由 `match()` 精确路由到当前 `(kind, id)` 的 post-start Match。它不再判断 Event 属于哪个 Context。 + +Assembler 按 `seq` 升序调用 `update()`。实时尾部 update 可以直接增量应用;任何非尾部证据插入、start 补齐或依赖失效都会从 `start()` 完整 replay。 + +没有业务变化时,`update()` 返回原 State。存在业务变化时,它可以返回 immutable replacement,也可以原地修改并返回同一对象。 + +Assembler 不以 State 引用相等判断是否需要发布或传播。每次成功 update 都增加 Context revision、标记 dirty,并使直接或传递 Reader 消费者重新求值。 + +#### `publication(match)` + +`publication()` 只决定最新 State 何时 materialize 成 view Node,不改变 `match()`、`start()` 或 `update()` 的同步执行。 + +| 返回值 | 行为 | +|---|---| +| `immediate` | 请求当前 microtask 通知与 flush | +| `animation-frame` | 把多条高频更新合并到下一帧 materialize | +| `none` | 本 Match 不主动安排 flush,State 和 dirty 标记仍被保留 | + +省略 `publication()` 等于 `immediate`。Assistant token delta 使用 `animation-frame`,不可见 Inbox Context 使用 `none`,final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。 + +一帧内的每条 delta 仍执行 update;合并的只是 `buildViewNode()`、View Builder 和 React snapshot 通知,不会丢失 token。 + +#### `buildLocationData(context, scope)` + +`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`。 + +Definition 分别收到 `step` 和 `turn` scope,可以在任一阶段返回一个值或 `null`。返回值必须声明准确的 turn/step 坐标,并使用与 Definition `kind` 相同的 key;Assembler 拥有替换和移除,并拒绝另一个 Context 占用同一 Location key。 + +`ConversationStepDataMap` 和 `ConversationTurnDataMap` 通过 declaration merging 约束 key 与 value。Location 只暴露稳定的 `data.get(key)` reader,消费者不能取得提供方 Context 或修改它的 State。 + +#### `buildViewNode(context, target)` + +`buildViewNode()` 在发布阶段读取最新 Context,为指定 target 直接生成最终业务 Node。Assembler 不在它之后附加通用 activity、tail candidate 或 layout 业务层。 + +`null` 表示该 Context 对这个 target 尚未 materialize。普通增量路径中,一个已经返回过非空 Node 的 Context 不能再返回 `null`;暂时隐藏必须保留同 key Node,并使用 target 自己的 visibility。 + +Assembler 校验 Node `key === context.key` 且 Node `target === target`。业务可以改变 `anchorSeq`、data、Location 或 visibility,但不能在一次生命周期内改变 identity。 + +`current` 让 Definition 区分“从未生成”与“已经生成后需要隐藏”。Assistant retry 和 Turn Error suppression 使用它避免非法的 Node 撤回。 + +Definition 可以针对 target 分支构造不同 data,但匹配、Context identity 和 State 保持 target-neutral。本次只注册 `chat` builder,Trajectory 仍通过兼容 slice 使用结果。 + +#### 不提供通用 `end()` + +引擎不提供固定 `end()` 生命周期。单 Event 业务在 `start()` 中完成,多 Event 业务在自己的 update 中记录完成,长期瞬间态业务则每条 Event 建立新 Context。 + +Step/Turn 关闭属于外部 Location 事实,不替业务修改 State。边界变化会 replay 并 build 受影响 Context;业务结合“自己的 State 是否完成”和“Location 是否 closed”生成正常、running 或 interrupted 表现。 + +ID 不复用,完成的 Context 继续存在于当前窗口,既提供稳定渲染 identity,也可以作为后续 Reader 的前序证据。 + +### Location 是一级引擎事实 + +[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。 + +Location 有 `session`、`turn`、`step` 和 `unresolved` 四种形状。Turn/Step 各自带 `open`、`closed` 或 `unknown` 状态,以及已加载的 start/end Event。 + +每个 Turn 和 Step 还持有 reference-stable 的 Location data store。Definition 更新只替换自己拥有的 key;同一个 store identity 可以随 append 或 prepend 获得新值,使 Context、View Builder 和 React renderer 共享已经确定的层级业务事实,而不复制或遍历全局 Node 数组。 + +`unresolved` 表示当前历史窗口缺少足够前序边界,不等于 session-level。older prepend 补入边界后,索引修正 Match Location,并只 replay 拥有这些 seq 的 Context。 + +Append 普通 Event 只继承当前坐标;append 边界只重算所属 Turn。Prepend 会基于扩展后的完整连续窗口重建 Location facts,但引用稳定逻辑保留未变化 Turn/Step 对象。 + +Assembler 还把 reference-stable timeline 交给 View Builder。业务不重复维护 turn order、step list、last step 或边界 Map。 + +## 三种事件窗口链路 + +“历史反扫”描述 UI 从最新尾页向 Session 起点逐页加载的方向,不表示 Definition 逆序执行 `update()`。无论历史 API 返回顺序或页面加载方向如何,Assembler 对每个当前窗口和每个 fresh page 都按 `seq` 升序 canonicalize。 + +| 场景 | 输入范围 | Context/State 处理 | View Builder | +|---|---|---|---| +| 初始历史尾页或 resync | 当前完整连续窗口 | 清空并按 `seq` 正序重建全部 Context | `replace()` | +| 加载一页 older history | 只传更早且去重后的 fresh Events | 保留现有 Context identity,补 Match、Location 和依赖后局部 replay | `apply(upserts)` | +| 实时 append | 一条连续尾部 Event | 只匹配 Definitions 并精确更新命中 ID,边界只影响所属 Turn | `apply(upserts)` | + +### 初始历史尾页与逻辑反扫 + +1. `Session.open()` 拉取最新 tail page,并把连续 History Entries 交给 `replaceWindow(entries, hasMore)`。 +2. `replaceWindow` 清空旧 Context、start-seq 索引、seq 反向索引、Reader 依赖和输入 Map。 +3. 全部 entries 按 Event `seq` 升序排序并写入当前窗口。 +4. LocationIndex 对这个窗口重建 Turn/Step facts。 +5. Assembler 按升序 Event 逐条调用每个普通 Definition 的 `match(event)`。 +6. 每个命中结果按 `(kind, id)` 取得或创建 Context,并把 Match 插入该 Context 的有序数组。 +7. 遇到 start 时执行 `start()`;已有 State 的尾部 update 直接执行 `update()`。 +8. 当前页只含 result/resource 而缺 start 时,Context 仍会按 ID 创建并收集 Matches,但 State 保持 `undefined`。 +9. 全部 Event 匹配后,Assembler 复查 Reader 依赖,使同一窗口内较早瞬间态先稳定、较晚消费者再读取它。 +10. 所有 Context 标记 dirty,下一次 flush 先按 Step→Turn 完整重建 Location data,再对每个 target 调用 `buildViewNode()`。 +11. 某些业务在缺 start 时返回 `null`;Compaction、Command、Tool result 或 Turn Error 等可根据充分 update 证据构造 fallback Node。 +12. 每个 View Builder 收到完整 Node 集和 timeline,通过 `replace()` 建立初始 snapshot。 + +这条链路“从最新页开始”只发生在分页选择层。页面内部 State 始终正序计算,因此同一个窗口不会因为扫描方向不同产生不同业务结果。 + +缺 start 的 Context 不是错误。它是等待 older 页补齐的 pending 聚合容器;是否提前可见由该 Definition 的 `buildViewNode()` 决定。 + +若当前页中的同 ID update 在日志顺序上真的早于 start,而不是仅仅先被加载,补齐 start 后 replay 会报协议错误。到达顺序可以反向,业务日志顺序不能反向。 + +### 新 older 分页的 prepend + +1. `Session.loadOlder()` 以当前 `baseSeq` 拉取紧邻前页,并先验证页尾与当前窗口连续。 +2. Session 把 raw Event/view 数组 prepend 到自己的窗口,只把这一页传给 `assembler.prepend(entries, hasMore)`。 +3. Assembler 按 seq 去掉与当前窗口重叠的 Events,再把 fresh page 内部升序排列。 +4. 已存在的 Context、State、current Nodes 和 View Builder 实例不清空。 +5. LocationIndex 用扩展后的完整输入重建 facts,并报告 Location identity 真正变化的 seq。 +6. 拥有这些 seq 的 Context 更新 Match Location,并从 start replay;无关 Context 不参与 Location replay。 +7. fresh Events 逐条执行 Definition matcher,并按稳定 ID 插入已有或新 Context 的有序 Matches。 +8. 新页补出 pending Context 的 start 时,该 Context 从 start 初始化,再正序应用已经收集的所有 updates。 +9. 新页建立更近的 Reader predecessor、改变 predecessor revision 或消除 window gap 时,消费者从 `start()` 重算。 +10. Reader 依赖沿 start seq 向后传递 replay;同一传播批次不会把 Event 逆序应用。 +11. `hasMore` 从 true 变为 false 的空页也会复查依赖,把暂定 `undefined` 收敛为确定不存在。 +12. flush 只为 dirty Context 重新发布 Step/Turn Location data 和 target Node,并把非空结果作为 `upserts` 交给 View Builder `apply()`。 + +Prepend 保留已有 Context key 和 current Node identity。新页可以在 Chat `order` 前部增加 key,也可以修正既有 Node 的 anchor、Location、visibility 或 data,但不会为无关业务重新创建 Context。 + +Chat Builder 遇到结构变化时会从 keyed store 重算可见 `order` 和 Location 二级索引;这是视图索引计算,不会重新执行全部业务 Definition 或替换未变化 Node value。 + +Reader gap 修复是 prepend 与普通 append 最大的算法差异。新页不仅可能创建可见历史 Node,也可能改变后续 Inbox 瞬间态以及依赖它的 Message 分类。 + +### 正向实时 append + +1. Session 只接受紧邻当前 tail seq 的 live Event;重叠 seq 去重,出现 gap 时先走 tail-page repair。 +2. 非边界 Event 增量写入当前 Turn/Step 坐标;边界 Event 更新所属 Turn 的 Location facts。 +3. Assembler 对这一个 Event 的每个普通 Definition 调用一次 `match()`,不会遍历任何 Definition 的 Context 集合。 +4. 每个命中结果通过 `(kind, id)` 直接定位一个 Context。 +5. 新 ID 创建 Context;已有 ID 的正常尾部 update 直接调用一次 `update()`。 +6. start 或任何需要插入非尾部位置的证据会走完整 `replayContext()`,保持同一正序语义。 +7. Context revision 变化后,只沿已登记 Reader 依赖 replay 消费者。 +8. Location close 会更新所属 Turn 中受影响 Match 的 Location,并 replay 这些 Context,使未完成 Assistant、Tool 或 Retry 得到 interrupted/cancelled 语气。 +9. Assembler 汇总所有命中 Definition 的 publication urgency;`immediate` 高于 `animation-frame`,后者高于 `none`。 +10. Session 把 immediate 交给 microtask notifier,把 animation-frame 交给 RAF notifier。 +11. flush 先为 dirty Context 更新 Step/Turn Location data,再调用 `buildViewNode()`,最后把本轮 upserts 和最新 timeline 交给 View Builder。 +12. React 订阅的新 snapshot 复用稳定 Context key;同一 Tool running→settled 或 Assistant streaming→final 不跨父节点移动。 + +Append 的业务匹配成本是 Definition 数量加实际命中的 Context 更新,不随历史 Context 数量增长。Reader 消费者和 Location 关闭会增加与真实依赖或所属 Turn 成比例的 replay。 + +Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新只替换 keyed store 中一个 Node,并 touch 所属 Location 索引。这里保证的是无关业务不 refold、Node identity 不替换,而不是宣称所有视图索引操作都是常数复杂度。 + +### Replace、prepend 与 append 的一致性 + +三条链路最终都遵守同一不变量:Context Matches 按 seq 排序,State 从唯一 start 正序 fold,Reader 只看严格前序 active Context,Location data 按 Step→Turn 发布,Node key 只由 kind 和 ID 决定。 + +`replaceWindow` 是初始打开、resync、gap repair 和 registry 变化的低频完整替换,不用于实现普通 load older。`prepend` 与 `append` 都保留现有 Builder 和 Context identity。 + +分页页宽、历史加载次数和 RAF 合批只影响何时得到更多证据或何时发布,不改变窗口证据相同时的最终 Context State 与 Node。 + +## 内建业务如何使用 Definition + +### 匹配、ID 与 State + +| 业务 / `kind` | 稳定 ID | start Match | update Matches | State 与跨 Context 读取 | +|---|---|---|---|---| +| Next-turn Inbox / `inbox-next-turn` | splice Event seq | 每条目标为 next-turn 的 `agent/inbox/spliced` | 无 | 从 `reader.previous(ownKind)` 的 pending/claimed 瞬间态应用当前 splice | +| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 同样形成逐指令瞬间态,claimed 集合供 Message 读取 | +| Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering | +| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data | +| Tool / `tool-call` | root call ID | root `tool/call` | root result、Code Dispatch start/result | 聚合 root、children 和 parent Map;Dispatch Event 用 `rootCallId` 精确路由 | +| Command / `command` | command ID | `command/run` | `command/done`、带 source command ID 的 compact lifecycle/checkpoint | 聚合 command outcome 和手动压缩证据 | +| Automatic Compaction / `compaction` | compaction ID | 无 source command ID 的 `compact/start` | summary、end、replacement checkpoint | 聚合 summary/checkpoint;checkpoint 足够时可在缺 start 下 fallback | +| Retry / `model-retry` | retry ID | attempt 1 的 `llm/retry` | 后续 `llm/retry` 与 `llm/retry-started` | 聚合同一 RetryId 的 attempts 与 scheduled/started 状态 | +| Turn Error / `turn-error` | turn number | `turn/start` | error `turn/end` 与该 turn Retry Events | 聚合 terminal failure,并用 Retry 证据决定隐藏 | +| Turn Tail / `turn-tail` | turn number | `turn/start` | Assistant、Retry、`step/end`、`turn/end` | 保存 turn end,读取各 Step 的 Assistant data,发布 Turn data;完整 Matches 用于选择视觉尾部 anchor | +| Deliverables / `deliverables` | turn number | `turn/start` | 该 Turn 的 Tool call/result | 聚合成功 mutation paths 并发布 Turn data,不生成 view Node | +| Unknown fallback / `unknown-surface` | Event seq | 未被普通 Definition 认领的 append-surface Event | 无 | 保存原始 type/data 作为 JSON fallback | + +### Chat Node 与历史/实时特性 + +| 业务 | `publication()` | Chat 产物 | 历史分页与运行时行为 | +|---|---|---|---| +| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算瞬间态 | +| Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 | +| Assistant | chunk 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | 缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 | +| Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallback;running→settled 保持 key | +| Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key | +| Compaction | 默认 immediate | `compaction` marker | checkpoint 可先展示,older 补 start 后正序 replay | +| Retry | 默认 immediate | 一个 `model-retry` Node 内含 attempts | 多次 retry 更新同一 key;Location close 把最后 scheduled 表现为 cancelled | +| Turn Error | 默认 immediate | `turn-error` visible/hidden | 缺 start 可从 error end fallback;Retry 到达后保留 key 并隐藏 | +| Turn Tail | 仅 `turn/end` immediate,其余 none | 独立 `turn-tail` footer | 从 Step Assistant data 计算 closing/metrics,并通过同 turn Matches 决定 anchor | +| Deliverables | 默认 immediate | 不生成 Node | Tool 结算增量更新所属 Turn data,Turn Tail 扩展槽读取 produced files | +| Fallback | 默认 immediate | `unknown` JSON row | 只兜底 append surface,普通业务已认领但暂不可见时不会重复生成 | + +Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。它通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。 + +Assistant、Turn Tail 和 Turn Error 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。 + +Assistant、Turn Tail 和 Deliverables 展示了 Location data 的分层组合。Assistant 负责写好每个 Step 的 `assistant-step` data;Turn Tail 从这些 Step values 计算 `turn-tail` data;Deliverables 独立维护同一 Turn 的 `deliverables` data。消费者只读取声明合并后的 key,不扫描其他业务 Node,也不取得提供方的 Context State。 + +Tool 和 Command 展示了多 Event 聚合:生产者提供共同 ID,Context 在业务内部构树或整合 Compaction,不把配对工作推给 Chat Builder。 + +Compaction 和历史 Tool result 展示了缺 start 时的业务 fallback。引擎不统一规定“没有 start 就不渲染”;Definition 根据当前 Matches 是否足够自行决定。 + +Retry 展示了业务 State 与 Location 的分工。scheduled/started 属于 Retry State;Step/Turn 是否关闭属于引擎 Location;`buildViewNode()` 组合两者得到 cancelled 视觉状态。 + +Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何普通 matcher 认领的 append surface Event,不会因为普通 Context 暂时返回 `null` 而误生成第二个 Node。 + +## View Builder 与 React identity + +[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。 + +Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。 + +[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline` 和 Trajectory 临时使用的 `legacy` slice。 + +Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value。 + +Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。 + +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 + +[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现仍位于 `ui-conversation`,但该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。 + +`conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 只把稳定 Node key 作为 `hookContext` 传给 slot;Slot renderer 用官方 standard props 中的 `useSession` 和该 key 构造 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。 + +Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径。后者继续只绑定 registration-owned Observable;前者按稳定 slot inject face 缓存定义,并按稳定 render occurrence 绑定 factory 和 Hook。`useTurnData()` 内部 selector 只返回当前 Node 的 `turn.data.get(key)`,无关 Session publication 会被 selector equality 截断。 + +标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,`useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。 + +Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat 的 data 和必要的排序属性,不再从末尾 running container 移入 finalized flow,因此组件内部 State 不因结算自动归零。 + +业务主动把已发布 Node 改成 hidden 时,它会退出 visible order,恢复 visible 时会重新 mount。这是明确的业务撤显语义,与 running→settled 的稳定 Seat 保证不同。 + +具体 Tool renderer 仍由 [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md) 约束。Tool Definition 只交付递归 root/subcall data,`ui-tool` 再按 Tool name keyed slot 分发具体表现。 + +Trajectory 尚未注册独立 target。它继续消费 Chat Builder 增量派生的 legacy slice,Session 不再运行第二套 transcript fold;未来迁移不改变 Event Definition、Context、Reader 或 Location 契约。 + +## Runtime and render path + +```text +Session Event window + -> ConversationNodeAssembler + -> Definition.match(event) -> (kind, id, start/update) + -> Context matches + State + Location + -> Definition.buildLocationData(step -> turn) + -> StepLocation.data / TurnLocation.data + -> Definition.buildViewNode(target = chat) + -> ChatSnapshotBuilder + -> order[] + keyed Node store + Location index + timeline + -> ChatView + -> ChatNodeSeat(key) + -> conversation.chat.node(entryKey = node.kind, hookContext = key) + -> slot-level useTurnData(businessKey) +``` + +## Verification + +Runtime tests 固定 Definition 生命周期注册、exact-ID append、update-before-start 收集与 start 后正序 replay、prepend identity、Reader window-gap 修复、传递依赖、Location closure、Step→Turn data phase order、Location data replacement、publication cadence、非法撤回和 per-target Builder。 + +Conversation tests 覆盖全部内建 Definition、Assistant Step data、Turn Tail 与 Deliverables Turn data、Chat 排序和结构共享、selector isolation、Assistant/Tool running-to-settled identity、nested Code Dispatch、steering、Compaction、Retry、interruption、load-older anchoring 和 slot dispatch。 + +Slot type/runtime tests 固定父注册必须提供声明的 common inject、`hookContext` 类型、不同 Node context 的 Hook 隔离、factory/Hook identity 稳定,以及无关 Session publication 不重渲染业务 renderer。原 entry-owned Observable Hook 测试继续固定未使用 contextual factory 的路径。 + +Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏览器证据比较 Assistant streaming→settled、Bash running→settled 以及 Code Mode root + nested subcalls 与 master 的布局。 + +历史链路验证同时覆盖完整 replace、非重叠 prepend、重叠 seq 去重、空页 `hasMore` 收敛和 live append。相同 Event 窗口通过不同摄入路径得到相同业务 State 与最终 Node。 + +## Alternatives considered + +**保留中心化 Session transcript fold,只抽 helper。** 拒绝:业务 identity、历史 replay 和 cache invalidation 仍属于一个闭合 switch,移动函数不会产生独立所有权。 + +**让 React renderer 自己扫描 Session Event。** 拒绝:每种 view 都会重复匹配和生命周期 State,React 会成为业务权威,paging 与 streaming 也会重算无关组件树。 + +**把全局 Nodes 或 Location 索引传给每个业务 renderer。** 拒绝:业务组件会自行扫描和推断当前 Turn/Step,订阅范围随窗口增长。Definition 把聚合值发布到 Engine-owned Location,renderer 只读取自己 Node 的 Location data。 + +**每个新 Event 都调用同 Definition 的全部 Context。** 拒绝:append 成本随历史增长,`update()` 也会同时承担匹配与转换。无 Context 的 `match(event)` 先算出 ID,随后只更新一个 Context。 + +**让 Definition 的 matcher 读取 Context 或扫描历史。** 拒绝:匹配将依赖摄入方向,result-first 历史页无法独立算出归属,实时 append 也退化成开放对象查找。 + +**为历史反扫定义逆向 State fold。** 拒绝:每个业务都要维护互为逆运算的两套逻辑,删除、非可逆聚合和跨 Context 依赖很难保持一致。统一 Matches 后从 start 正序 replay 只有一套业务语义。 + +**把 Inbox 做成引擎一级公民或一个窗口级 Context。** 拒绝:Inbox 是普通业务状态,不应污染通用引擎;逐 splice 瞬间态加严格前序 Reader 同时支持 prepend、append 和 Message 查询。 + +**给跨业务查询注册特化 query method。** 拒绝:消费者仍要依赖提供方 API,新增关系会扩张中心接口。Reader 暴露指定 kind 的只读前序 Context,由提供方写好 State、消费者读懂 State。 + +**让 Location data 消费者直接读取提供方 Context State。** 拒绝:消费者会依赖另一个业务的可变内部形状,也无法表达值属于哪个 Turn/Step。declaration-merged data map 只公开提供方选择发布的只读值和 Engine-owned 坐标。 + +**增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 State,Location close 触发 replay/build,Reader dependency 负责补页失效。 + +**为 Chat 与 Trajectory 注册两套 Event Definition。** 拒绝:identity、State 和 Location 与 target 无关。视图差异由 `buildViewNode(target)` 和各自 Builder 表达;Trajectory 在真正迁移前保留兼容 slice。 + +**在最终业务 Node 上再叠一层通用 layout model。** 拒绝:activity、tail candidacy 和 layout enum 会把当前 Chat 的业务语义重新集中到引擎。最终 Node 直接携带 renderer 所需 data,只共享 identity、排序和 Location 事实。 + +**只在 Assistant renderer 注册 Turn data Hook。** 拒绝:访问当前 Node Location 是 `conversation.chat.node` slot 的公共能力,不属于某个业务 renderer。父 Chat entry 注册一次 common inject,所有 keyed renderer 共享同一强类型契约。 + +**把 running Assistant 或 Tool 保留在独立 tail container。** 拒绝:结算时会跨 React parent 移动,稳定业务 key 也无法阻止 remount。统一 keyed order 允许 data 和排序位置改变,但不改变 Seat identity。 + +## Consequences + +新增业务节点可以局部注册自己的 matcher、State 转换、可选 Location data、最终 target Node 和 renderer,不再修改 Session 的业务 switch。`ChatNodeDataMap` 和 Location data maps 允许业务 package 通过 declaration merging 合入强类型 data;所有相关 Event 仍须暴露可单 Event 推导的稳定 ID。 + +初始尾页、older prepend 和 live append 共享一套 Context 不变量。缺 start、Reader window gap、Location unknown 以及高频 delta 都是引擎明确表达的状态,不需要业务另建方向相关 cache。 + +Append 不扫描历史 Context;prepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。 + +State 更新与发布频率分离后,Assistant 每条 delta 都被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。 + +Step/Turn 成为业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 不再依赖 renderer 扫描全局 Nodes;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。 + +代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。Definition 作者必须理解稳定 ID、唯一 start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。 + +`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 在 Trajectory 迁移前仍维护 legacy slice;内建 Definitions 暂时集中在 `ui-conversation`。这些是兼容边界,不把业务解释权交还给 Session。 From aa623b6e7ac5c83fa1f2ea4fe80e124b2d5d66bd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:48:07 +0800 Subject: [PATCH 05/32] refactor(events): add stable conversation correlation ids --- packages/compact/command-compact/src/index.ts | 2 +- packages/compact/compact-basic/package.json | 2 + packages/compact/compact-basic/src/index.ts | 9 +- packages/compact/compact-basic/src/region.ts | 40 ++++++-- packages/compact/compact-basic/tsconfig.json | 3 + packages/compact/compact/package.json | 8 ++ packages/compact/compact/src/brand.ts | 13 +++ packages/compact/compact/src/checkpoint.ts | 25 +++++ packages/compact/compact/src/index.ts | 7 +- packages/compact/compact/src/invariant.ts | 93 ++++++++++++++++++- packages/compact/compact/src/types.ts | 12 ++- packages/compact/compact/tsconfig.json | 6 ++ packages/compact/compact/tsdown.config.ts | 13 +++ packages/core/tools/src/code-mode.ts | 7 +- packages/core/tools/src/index.ts | 9 ++ packages/core/tools/src/invariant.ts | 28 ++++++ packages/interaction/commands/src/index.ts | 4 +- packages/llm/llm-retry/package.json | 6 ++ packages/llm/llm-retry/src/brand.ts | 13 +++ packages/llm/llm-retry/src/index.ts | 36 +++---- packages/llm/llm-retry/src/invariant.ts | 37 +++++++- packages/llm/llm-retry/src/types.ts | 12 +++ packages/llm/llm-retry/tsconfig.json | 3 + 23 files changed, 347 insertions(+), 41 deletions(-) create mode 100644 packages/compact/compact/src/brand.ts create mode 100644 packages/compact/compact/tsdown.config.ts create mode 100644 packages/llm/llm-retry/src/brand.ts diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts index 4ac171a689..b3f80d87fd 100644 --- a/packages/compact/command-compact/src/index.ts +++ b/packages/compact/command-compact/src/index.ts @@ -63,7 +63,7 @@ async function executeCompact( return { kind: 'error', text: USAGE } } try { - const result = await ctx.compact.compactNow(invocation.agent, invocation.signal) + const result = await ctx.compact.compactNow(invocation.agent, invocation.signal, invocation.commandId) if (result === null) return { kind: 'success', text: 'No compactable history yet.' } return { kind: 'success', diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 2b0624e06e..3fffee14a7 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-compact": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 27b59d0451..d3c710bef3 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -13,6 +13,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' // Type-only: makes the optional sibling service available to `ctx.get()`. import type {} from '@deepseek-ai/dsh-compact-tool-result-prune' import { @@ -361,9 +362,14 @@ export class BasicCompactService extends CompactService { * resolve only after its standalone marker pair is durably checkpointed. * @param agent - idle agent whose next-turn admission this call reserves. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for presentation correlation. * @returns the committed result, or `null` when no safe useful range exists. */ - override compactNow(agent: Agent, signal: AbortSignal): Promise { + override compactNow( + agent: Agent, + signal: AbortSignal, + sourceCommandId?: CommandId, + ): Promise { signal.throwIfAborted() try { return agent.runMaintenance(async (agentSignal) => { @@ -385,6 +391,7 @@ export class BasicCompactService extends CompactService { { owner: null, stability: 'selected-span', + ...sourceCommandId === undefined ? {} : { sourceCommandId }, flush: async () => { await this.ctx.sessions.flush(agent.session) }, diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index b220074e02..ae637d03cc 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -5,14 +5,17 @@ * @module @deepseek-ai/dsh-compact-basic/region */ +import { randomUUID } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' import { - COMPACT_CHECKPOINT_SOURCE, + CompactionId, ManualCompactionError, + compactCheckpointSource, toolPairingBalancedAfter, toolPairingBalancedBefore, } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { Message, UserMessage } from '@deepseek-ai/dsh-llm' import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter' @@ -54,6 +57,8 @@ interface CompactionTransactionOptions { readonly stability: 'whole-surface' | 'selected-span' /** Optional durability checkpoint after a successfully closed bracket. */ readonly flush?: () => Promise + /** Manual command that initiated this transaction, when present. */ + readonly sourceCommandId?: CommandId } interface CompactionEntryState { @@ -175,7 +180,13 @@ export async function compactSurfaceRegion( owner = entryState.openTurn } - const startEvent = session.append('compact/start', { turn: owner }) + const compactionId = CompactionId(randomUUID()) + const lifecycle = { + compactionId, + ...options.sourceCommandId === undefined ? {} : { sourceCommandId: options.sourceCommandId }, + turn: owner, + } + const startEvent = session.append('compact/start', lifecycle) const assertStable: StabilityCheck = options.stability === 'whole-surface' ? assertWholeSurfaceUnchanged : assertSelectedSpanStable @@ -188,13 +199,20 @@ export async function compactSurfaceRegion( try { const prepared = prepareCompaction(dependencies, session, selection) - const summarized = await summarizeCompaction(dependencies, prepared, agent, signal) + const summarized = await summarizeCompaction( + dependencies, + prepared, + agent, + compactionId, + options.sourceCommandId, + signal, + ) if (options.owner === null) signal?.throwIfAborted() assertStable(dependencies, session, summarized) stage = 'commit' const pending = commitCompactionBody(session, startEvent, summarized) closing = true - const endEvent = session.append('compact/end', { turn: owner }) + const endEvent = session.append('compact/end', lifecycle) closed = true result = completeCompaction(pending, endEvent) } catch (error: unknown) { @@ -202,7 +220,7 @@ export async function compactSurfaceRegion( if (!closing) { closing = true try { - session.append('compact/end', { turn: owner, error: errorChain(error) }) + session.append('compact/end', { ...lifecycle, error: errorChain(error) }) closed = true } catch (closeError: unknown) { failure = { error: closeError, stage: 'commit' } @@ -343,12 +361,14 @@ async function summarizeCompaction( dependencies: RegionDependencies, prepared: PreparedCompaction, agent: Agent, + compactionId: CompactionResult['compactionId'], + sourceCommandId: CommandId | undefined, signal?: AbortSignal, ): Promise { const summaryResult = await dependencies.summarize(prepared.input, agent, signal) const checkpointMessage = createUserMessage({ content: frameSummary(summaryResult.summary), - source: COMPACT_CHECKPOINT_SOURCE, + source: compactCheckpointSource(compactionId, sourceCommandId), }) const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage) if (framedSummaryTokenCount >= prepared.shadowedTokenCount) { @@ -425,6 +445,10 @@ function commitCompactionBody( ? { rawOutput: summarized.rawOutput, llmStreamCall: true as const } : summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput } const summaryEvent = session.append('compact/summary', { + compactionId: startEvent.data.compactionId, + ...startEvent.data.sourceCommandId === undefined + ? {} + : { sourceCommandId: startEvent.data.sourceCommandId }, summary, ...callProvenance, shadowedRange: { start, end }, @@ -440,6 +464,10 @@ function commitCompactionBody( sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], }) return { + compactionId: startEvent.data.compactionId, + ...startEvent.data.sourceCommandId === undefined + ? {} + : { sourceCommandId: startEvent.data.sourceCommandId }, startSeq: startEvent.seq, summarySeq: summaryEvent.seq, summary, diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json index bd1a440119..f90011f04d 100644 --- a/packages/compact/compact-basic/tsconfig.json +++ b/packages/compact/compact-basic/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../../core/agent" }, + { + "path": "../../interaction/commands" + }, { "path": "../compact" }, diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 100a3f56f8..bb4150ee32 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -19,6 +19,10 @@ "types": "./lib/types/checkpoint.d.ts", "default": "./lib/types/checkpoint.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -30,12 +34,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/compact/compact/src/brand.ts b/packages/compact/compact/src/brand.ts new file mode 100644 index 0000000000..5b6de03e61 --- /dev/null +++ b/packages/compact/compact/src/brand.ts @@ -0,0 +1,13 @@ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable identity shared by one compact start/summary/checkpoint/end transaction. */ +export type CompactionId = Branded<'CompactionId'> + +/** + * Brand an implementation-minted compaction identity. + * @param id - opaque transaction identity. + * @returns the same string, branded; no validation is performed. + */ +export function CompactionId(id: string): CompactionId { + return id as CompactionId +} diff --git a/packages/compact/compact/src/checkpoint.ts b/packages/compact/compact/src/checkpoint.ts index 9d8b98e4d6..9908fe4ac0 100644 --- a/packages/compact/compact/src/checkpoint.ts +++ b/packages/compact/compact/src/checkpoint.ts @@ -13,10 +13,35 @@ */ import type { MessageSource } from '@deepseek-ai/dsh-llm/message' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { CompactionId } from './brand.ts' /** Canonical source for the replacement user message produced by every compaction backend. */ export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const) +/** Message provenance carried by a concrete compaction checkpoint. */ +export type CompactCheckpointSource = typeof COMPACT_CHECKPOINT_SOURCE & { + readonly compactionId: CompactionId + readonly sourceCommandId?: CommandId +} + +/** + * Create checkpoint provenance correlated with one compaction transaction. + * @param compactionId - owning compaction identity. + * @param sourceCommandId - initiating manual command, when present. + * @returns immutable checkpoint source. + */ +export function compactCheckpointSource( + compactionId: CompactionId, + sourceCommandId?: CommandId, +): CompactCheckpointSource { + return Object.freeze({ + ...COMPACT_CHECKPOINT_SOURCE, + compactionId, + ...sourceCommandId === undefined ? {} : { sourceCommandId }, + }) +} + /** * Test whether a persisted message source identifies a compaction checkpoint. * @param source - source restored from a surface user message. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 7b0bd25091..0085321333 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -9,14 +9,17 @@ import { Context, Service } from 'cordis' import type { Session } from '@deepseek-ai/dsh-session' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' +export { CompactionId } from './brand.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' // The checkpoint source and its predicate are declared on the cordis-free // `./checkpoint` leaf so client and wire programs can name them without this // root's Context merge; the root stays the host-side entry point for both. -export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoint.ts' +export { COMPACT_CHECKPOINT_SOURCE, compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts' +export type { CompactCheckpointSource } from './checkpoint.ts' /** Why automatic policy is asking a backend to consider compaction. */ export type CompactionTrigger = 'pressure' | 'context-overflow' @@ -126,6 +129,7 @@ export abstract class CompactService extends Service { * * @param agent - idle agent whose durable history should be compacted. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for a manual compaction. * @returns the compaction result, or `null` when no safe useful range exists. * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, * changed-span, summarization/shrink, commit-stage, or persistence failures; @@ -135,6 +139,7 @@ export abstract class CompactService extends Service { abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, + sourceCommandId?: CommandId, ): Promise /** diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index 221d5aae09..adac557db5 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -1,8 +1,12 @@ /** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */ import type { Context } from 'cordis' +import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { CompactionId } from './brand.ts' +import { isCompactCheckpointSource } from './checkpoint.ts' +import type { CompactCheckpointSource } from './checkpoint.ts' import type {} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-compact' @@ -13,6 +17,8 @@ export const name = 'compact-invariant' export const inject = ['invariants'] interface CompactionTrace { + compactionId: CompactionId + sourceCommandId: string | undefined startSeq: number turn: number | null summarized: boolean @@ -24,11 +30,48 @@ interface SessionTrace { } type CompactionTransition = - | { kind: 'start'; startSeq: number; turn: number | null } - | { kind: 'summary'; startSeq: number; turn: number | null } + | { kind: 'start'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null } + | { kind: 'summary'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null } | { kind: 'end' } | { kind: 'end-seed' } +/** Require a durable opaque identity to be a non-empty string. */ +function validateId(value: unknown, label: string, fail: InvariantFailure): asserts value is string { + if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`) +} + +/** Keep the optional initiating command identity stable across one transaction. */ +function validateSourceCommandId( + eventType: string, + value: unknown, + expected: string | undefined, + fail: InvariantFailure, +): void { + if (value !== undefined) validateId(value, `${eventType} sourceCommandId`, fail) + if (value !== expected) { + fail(`${eventType} sourceCommandId ${String(value)} does not match compact/start sourceCommandId ${String(expected)}`) + } +} + +/** Validate one replacement checkpoint against its open compaction transaction. */ +function validateCheckpoint( + trace: SessionTrace, + event: SessionEvent<'user/message'>, + fail: InvariantFailure, +): void { + const source = event.data.source as typeof event.data.source & Partial + validateId(source.compactionId, 'compaction checkpoint compactionId', fail) + if (source.sourceCommandId !== undefined) { + validateId(source.sourceCommandId, 'compaction checkpoint sourceCommandId', fail) + } + const open = trace.compaction + if (open === undefined) fail('compaction checkpoint has no matching compact/start') + if (source.compactionId !== open.compactionId) { + fail(`compaction checkpoint id ${source.compactionId} does not match compact/start id ${open.compactionId}`) + } + validateSourceCommandId('compaction checkpoint', source.sourceCommandId, open.sourceCommandId, fail) +} + /** Compaction starts still unmatched when a later seed boundary made them stale. */ function inheritedOrphanStartSeqs( events: readonly SessionEvent[], @@ -99,20 +142,44 @@ function validateCompactionEvent( fail: InvariantFailure, ): CompactionTransition | undefined { if (event.type === 'session/end-seed') return { kind: 'end-seed' } + if (event.type === 'user/message' + && isReplacementSurfaceEvent(event) + && isCompactCheckpointSource(event.data.source)) { + validateCheckpoint(trace, event, fail) + return undefined + } if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') { return undefined } const open = trace.compaction if (event.type === 'compact/start') { + validateId(event.data.compactionId, 'compact/start compactionId', fail) + if (event.data.sourceCommandId !== undefined) { + validateId(event.data.sourceCommandId, 'compact/start sourceCommandId', fail) + } if (open !== undefined) { const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}` fail(`compact/start while ${owner} is still compacting`) } validateOwner(event.data.turn, trace.openTurn, event.type, fail) - return { kind: 'start', startSeq: event.seq, turn: event.data.turn } + return { + kind: 'start', + compactionId: event.data.compactionId, + sourceCommandId: event.data.sourceCommandId, + startSeq: event.seq, + turn: event.data.turn, + } } if (event.type === 'compact/summary') { + validateId(event.data.compactionId, 'compact/summary compactionId', fail) + if (event.data.sourceCommandId !== undefined) { + validateId(event.data.sourceCommandId, 'compact/summary sourceCommandId', fail) + } if (open === undefined) fail('compact/summary has no matching compact/start') + if (event.data.compactionId !== open.compactionId) { + fail(`compact/summary id ${event.data.compactionId} does not match compact/start id ${open.compactionId}`) + } + validateSourceCommandId('compact/summary', event.data.sourceCommandId, open.sourceCommandId, fail) validateOwner(open.turn, trace.openTurn, event.type, fail) if (open.summarized) fail('compact/summary repeated within one compaction') const seqs = event.data.shadowedSeqs @@ -123,9 +190,23 @@ function validateCompactionEvent( if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) { fail('compact/summary shadowedTokenCount must be a non-negative safe integer') } - return { kind: 'summary', startSeq: open.startSeq, turn: open.turn } + return { + kind: 'summary', + compactionId: open.compactionId, + sourceCommandId: open.sourceCommandId, + startSeq: open.startSeq, + turn: open.turn, + } + } + validateId(event.data.compactionId, 'compact/end compactionId', fail) + if (event.data.sourceCommandId !== undefined) { + validateId(event.data.sourceCommandId, 'compact/end sourceCommandId', fail) } if (open === undefined) fail('compact/end has no matching compact/start') + if (event.data.compactionId !== open.compactionId) { + fail(`compact/end id ${event.data.compactionId} does not match compact/start id ${open.compactionId}`) + } + validateSourceCommandId('compact/end', event.data.sourceCommandId, open.sourceCommandId, fail) if (event.data.turn !== open.turn) { fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`) } @@ -142,6 +223,8 @@ function applyCompactionTransition( ): CompactionTrace | undefined { if (transition.kind === 'start') { return { + compactionId: transition.compactionId, + sourceCommandId: transition.sourceCommandId, startSeq: transition.startSeq, turn: transition.turn, summarized: false, @@ -149,6 +232,8 @@ function applyCompactionTransition( } if (transition.kind === 'summary') { return { + compactionId: transition.compactionId, + sourceCommandId: transition.sourceCommandId, startSeq: transition.startSeq, turn: transition.turn, summarized: true, diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 4429e8b291..c321be426e 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -8,6 +8,8 @@ */ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { CompactionId } from './brand.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -16,7 +18,7 @@ declare module '@deepseek-ai/dsh-session' { * `compact/end`. A numbered owner is strictly enclosed by that open turn; * `null` identifies a standalone manual transaction between turns. */ - 'compact/start': { turn: number | null } + 'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } /** * Completed summary, its inputs, and its model call facts — log-only, no surfaceOp. * The summary content is in `data.summary`; the actual surface replacement @@ -27,6 +29,8 @@ declare module '@deepseek-ai/dsh-session' { * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { + compactionId: CompactionId + sourceCommandId?: CommandId summary: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] @@ -62,7 +66,7 @@ declare module '@deepseek-ai/dsh-session' { * Marks the end of a compaction — log-only, releases the lock. Its owner * matches `compact/start`; `error` records an unsuccessful attempt. */ - 'compact/end': { turn: number | null; error?: string } + 'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } /** * Shadow price of one model-free prune replacement — log-only, no * surfaceOp. The shared shadow-price protocol: a surface `replace` event @@ -85,6 +89,10 @@ declare module '@deepseek-ai/dsh-session' { /** Result of a successful compaction operation. */ export interface CompactionResult { + /** Stable identity shared by this compaction's complete durable lifecycle. */ + compactionId: CompactionId + /** Human command that initiated this compaction, when it was manual. */ + sourceCommandId?: CommandId /** The seq of the appended `compact/start` event. */ startSeq: number /** The seq of the appended `compact/summary` event. */ diff --git a/packages/compact/compact/tsconfig.json b/packages/compact/compact/tsconfig.json index 673ee51547..a231786c32 100644 --- a/packages/compact/compact/tsconfig.json +++ b/packages/compact/compact/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cosmokit" }, @@ -17,6 +20,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../interaction/commands" + }, { "path": "../../core/session" }, diff --git a/packages/compact/compact/tsdown.config.ts b/packages/compact/compact/tsdown.config.ts new file mode 100644 index 0000000000..6284421125 --- /dev/null +++ b/packages/compact/compact/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown' + +/** Builds each published entry as a self-contained file admitted by the package whitelist. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 2ec5a1bb25..63c5b2cb9b 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -30,7 +30,7 @@ declare module '@deepseek-ai/dsh-session' { * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ - 'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } + 'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } /** * One bridged sub-dispatch SETTLING: the pairing ids (matching the * `tool/code-dispatch-start` with the same `subCallId`), the tool `name` @@ -46,7 +46,7 @@ declare module '@deepseek-ai/dsh-session' { * before returning), so its execution-enclosure relation holds by * construction. */ - 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } + 'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } } } @@ -502,6 +502,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge const subCallId = CallId(`${String(exec.callId)}:code:${n}`) const input = { callId: subCallId, + rootCallId: exec.rootCallId, name, arguments: normalized.dispatched, ...exec.agent ? { agent: exec.agent } : {}, @@ -539,6 +540,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge content: result.content, }) agent.session.append('tool/code-dispatch', { + rootCallId: exec.rootCallId, parentCallId: exec.callId, subCallId, name, @@ -563,6 +565,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge }, async start(): Promise { exec.agent?.session.append('tool/code-dispatch-start', { + rootCallId: exec.rootCallId, parentCallId: exec.callId, subCallId, name, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 7c2346e723..6338e54501 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -297,6 +297,11 @@ export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: */ export interface ToolExecutionInput { readonly callId: CallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: CallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -352,6 +357,8 @@ export interface CodeDispatchLog { * observers run. */ export interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: CallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } @@ -1123,6 +1130,7 @@ export class ToolRegistry extends Service { const deferredContexts: UserMessage[] = [] const token = createExecutionToken() const callId = exec.callId + const rootCallId = exec.rootCallId ?? callId const name = exec.name const agent = exec.agent const parent = exec.parent @@ -1133,6 +1141,7 @@ export class ToolRegistry extends Service { const base = { token, callId, + rootCallId, name, signal, ...agent !== undefined ? { agent } : {}, diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 78666f5890..a0d9487857 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -33,9 +33,34 @@ function validateResult( const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const stages = new WeakMap() const openTurns = new WeakMap() + const dispatchRoots = new WeakMap>() + const validateDispatch = (session: Session, event: SessionEvent): void => { + if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return + const root = String(event.data.rootCallId) + const parent = String(event.data.parentCallId) + const child = String(event.data.subCallId) + if (root.length === 0 || parent.length === 0 || child.length === 0) { + fail(`${event.type} must carry non-empty rootCallId, parentCallId, and subCallId`) + return + } + const roots = dispatchRoots.get(session) + const known = roots?.get(child) + if (known !== undefined && known !== root) fail(`${event.type} changed rootCallId for subCallId ${child}`) + if (parent !== root && roots?.get(parent) !== root) { + fail(`${event.type} parentCallId ${parent} does not belong to rootCallId ${root}`) + } + } + const commitDispatch = (session: Session, event: SessionEvent): void => { + if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return + const roots = dispatchRoots.get(session) as Map + roots.set(String(event.data.subCallId), String(event.data.rootCallId)) + } const seed = (session: Session): number | null => { let openTurn: number | null = null + dispatchRoots.set(session, new Map()) for (const event of session.events) { + validateDispatch(session, event) + commitDispatch(session, event) if (event.type === 'turn/start') openTurn = event.data.turn else if (event.type === 'turn/end') openTurn = null else if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') @@ -51,12 +76,15 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('session/event', (session, event) => { + validateDispatch(session, event) + commitDispatch(session, event) if (event.type === 'turn/start') openTurns.set(session, event.data.turn) else if (event.type === 'turn/end') openTurns.set(session, null) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName === 'session/event') { const [session, event] = args as [Session, SessionEvent] + validateDispatch(session, event) if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') && openTurnFor(session) === null) { fail(`${event.type} appended outside any open turn`) diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 94c4861e66..780569c159 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -37,6 +37,8 @@ export interface CommandInputDescriptor { /** Invocation passed to one registered command handler. */ export interface CommandInvocation { + /** Pairing id already written to this invocation's `command/run` event. */ + readonly commandId: CommandId /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ @@ -389,7 +391,7 @@ export class CommandService extends Service { ...command.definition.recordInput === false ? {} : { args: parsed.rawInput }, source: { kind: 'user' }, }) - const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) + const invocation = Object.freeze({ commandId, agent, rawInput: parsed.rawInput, signal }) let result: CommandResult try { const output = command.definition.handler(invocation) diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index d3b6afebba..1685d0e552 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -19,6 +19,10 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, "./package.json": "./package.json" }, "files": [ @@ -29,6 +33,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -40,6 +45,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", diff --git a/packages/llm/llm-retry/src/brand.ts b/packages/llm/llm-retry/src/brand.ts new file mode 100644 index 0000000000..41682cc9d1 --- /dev/null +++ b/packages/llm/llm-retry/src/brand.ts @@ -0,0 +1,13 @@ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable identity shared by every attempt in one request-step retry chain. */ +export type RetryId = Branded<'RetryId'> + +/** + * Brand an implementation-minted retry-chain identity. + * @param id - opaque retry identity. + * @returns the same string, branded; no validation is performed. + */ +export function RetryId(id: string): RetryId { + return id as RetryId +} diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 82b441fb8f..dcba1e2443 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -5,39 +5,26 @@ * @module @deepseek-ai/dsh-llm-retry */ +import { randomUUID } from 'node:crypto' import type { Context, Events } from 'cordis' import z from 'schemastery' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { RetryId } from './brand.ts' +import type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ - 'llm/retry': { - turn: number - step: number - provider: string - mode: 'normal' - policyKey: string - retry: number - maxRetries: number - delayMs: number - failure: LlmFailure - } | { - turn: number - step: number - provider: string - mode: 'always' - policyKey: string - retry: number - delayMs: number - failure: LlmFailure - } + 'llm/retry': LlmRetryEventData + /** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ + 'llm/retry-started': LlmRetryStartedEventData } } -export type { LlmRetryEventData } from './types.ts' +export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' +export { RetryId } from './brand.ts' export const name = 'llm-retry' export const inject = ['agents'] @@ -139,6 +126,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna policy: ResolvedRetryPolicy, policyKey: string, retry: number, + retryId: RetryId, delayMs: number, signal: AbortSignal, ): Promise { @@ -146,6 +134,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna if (fusedSignal.aborted) return const eventData = policy.mode === 'normal' ? { + retryId, turn, step, provider, @@ -157,6 +146,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna failure, } : { + retryId, turn, step, provider, @@ -168,6 +158,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } agent.session.append('llm/retry', eventData) if (!await cancellableDelay(delayMs, fusedSignal)) return + agent.session.append('llm/retry-started', { retryId, turn, step, retry }) return { kind: 'retry' } } @@ -207,6 +198,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna const previousRetry = priorPolicyRetry?.data.retry ?? 0 if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() const retry = previousRetry + 1 + const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID()) let delayMs: number if (failure.providerRetryAfterMs !== undefined && Number.isFinite(failure.providerRetryAfterMs) @@ -221,7 +213,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna delayMs = localDelay(policy, retry, random) } - return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, delayMs, signal) + return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, retryId, delayMs, signal) } const disposeListener = ctx.on('agent/request-error', ( diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index d324c012f2..1680873c0c 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -47,7 +47,10 @@ function validateRetry( event: SessionEvent<'llm/retry'>, fail: InvariantFailure, ): void { - const { turn, step, provider, mode, policyKey, retry, delayMs } = event.data + const { retryId, turn, step, provider, mode, policyKey, retry, delayMs } = event.data + if (typeof retryId !== 'string' || retryId.length === 0) { + fail('llm/retry retryId must be a non-empty string') + } const failure: unknown = event.data.failure validateFailure(failure, fail) if (!Number.isSafeInteger(retry) || retry < 1) { @@ -110,12 +113,43 @@ function validateRetry( if (retry !== expectedRetry) { fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`) } + if (priorPolicyRetry !== undefined && priorPolicyRetry.data.retryId !== retryId) { + fail('llm/retry must preserve retryId across one provider-policy chain') + } + if (priorPolicyRetry === undefined && history.some(prior => + (prior.type === 'llm/retry' || prior.type === 'llm/retry-started') + && prior.data.retryId === retryId)) { + fail(`llm/retry retryId ${JSON.stringify(retryId)} is already owned by another chain`) + } +} + +/** Validate one wait-complete transition against its scheduled attempt. */ +function validateStarted( + history: readonly SessionEvent[], + event: SessionEvent<'llm/retry-started'>, + fail: InvariantFailure, +): void { + const { retryId, turn, step, retry } = event.data + if (typeof retryId !== 'string' || retryId.length === 0) { + fail('llm/retry-started retryId must be a non-empty string') + } + const scheduled = history.findLast((prior): prior is SessionEvent<'llm/retry'> => + prior.type === 'llm/retry' && prior.data.retryId === retryId && prior.data.retry === retry) + if (scheduled === undefined) fail('llm/retry-started pairs no prior scheduled attempt') + if (scheduled.data.turn !== turn || scheduled.data.step !== step) { + fail('llm/retry-started turn/step must match its scheduled attempt') + } + if (history.some(prior => prior.type === 'llm/retry-started' + && prior.data.retryId === retryId && prior.data.retry === retry)) { + fail('llm/retry-started repeats one scheduled attempt') + } } /** Validate every retry record already present in one loaded session. */ function validateSession(session: Session, fail: InvariantFailure): void { for (const [index, event] of session.events.entries()) { if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail) + else if (event.type === 'llm/retry-started') validateStarted(session.events.slice(0, index), event, fail) } } @@ -127,6 +161,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (event.type === 'llm/retry') validateRetry(session.events, event, fail) + else if (event.type === 'llm/retry-started') validateStarted(session.events, event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts index f59aef4495..12820889dd 100644 --- a/packages/llm/llm-retry/src/types.ts +++ b/packages/llm/llm-retry/src/types.ts @@ -1,8 +1,10 @@ import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' +import type { RetryId } from './brand.ts' /** Durable payload recorded before one provider-routed model-request retry wait. */ export type LlmRetryEventData = | { + retryId: RetryId turn: number step: number provider: string @@ -13,7 +15,9 @@ export type LlmRetryEventData = delayMs: number failure: LlmFailure } + | { + retryId: RetryId turn: number step: number provider: string @@ -23,3 +27,11 @@ export type LlmRetryEventData = delayMs: number failure: LlmFailure } + +/** Durable transition recorded after one retry delay completes. */ +export interface LlmRetryStartedEventData { + retryId: RetryId + turn: number + step: number + retry: number +} diff --git a/packages/llm/llm-retry/tsconfig.json b/packages/llm/llm-retry/tsconfig.json index 48c858951a..41e9ca65c5 100644 --- a/packages/llm/llm-retry/tsconfig.json +++ b/packages/llm/llm-retry/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cosmokit" }, From 81d688600677eabab42fbb9f364ed5a3d7ce7b30 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:48:19 +0800 Subject: [PATCH 06/32] feat(client): add conversation node engine and contextual slots --- packages/client/runtime/package.json | 1 - .../src/client/contract/conversation.ts | 261 ++++++ .../conversation/definition-registry.ts | 60 ++ .../src/client/conversation/event-registry.ts | 56 ++ .../src/client/conversation/view-registry.ts | 26 + packages/client/runtime/src/client/index.ts | 39 +- .../src/client/sessions/assistant-timing.ts | 7 +- .../client/sessions/conversation-assembler.ts | 798 ++++++++++++++++++ .../sessions/conversation-location-index.ts | 508 +++++++++++ .../src/client/sessions/conversation.ts | 91 +- .../runtime/src/client/sessions/manager.ts | 15 +- .../runtime/src/client/sessions/partial.ts | 9 +- .../src/client/sessions/queue-mirror.ts | 74 ++ .../runtime/src/client/sessions/service.ts | 36 +- .../runtime/src/client/sessions/session.ts | 454 ++-------- .../src/client/sessions/transcript-adapter.ts | 409 --------- packages/client/runtime/src/client/slots.ts | 2 +- packages/client/ui-slots/src/index.ts | 192 ++++- packages/client/ui-slots/src/renderer.ts | 4 +- .../client/web-react/src/scoped-slots.tsx | 333 ++++++-- 20 files changed, 2475 insertions(+), 900 deletions(-) create mode 100644 packages/client/runtime/src/client/contract/conversation.ts create mode 100644 packages/client/runtime/src/client/conversation/definition-registry.ts create mode 100644 packages/client/runtime/src/client/conversation/event-registry.ts create mode 100644 packages/client/runtime/src/client/conversation/view-registry.ts create mode 100644 packages/client/runtime/src/client/sessions/conversation-assembler.ts create mode 100644 packages/client/runtime/src/client/sessions/conversation-location-index.ts create mode 100644 packages/client/runtime/src/client/sessions/queue-mirror.ts delete mode 100644 packages/client/runtime/src/client/sessions/transcript-adapter.ts diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 3d97e70de5..3176428b36 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -33,7 +33,6 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts new file mode 100644 index 0000000000..fb98965d25 --- /dev/null +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -0,0 +1,261 @@ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client' + +/** One raw log event plus its optional envelope-level presentation view. */ +export interface ConversationEventInput { + readonly event: SessionEvent + readonly view: ToolEventView | undefined +} + +/** Definition-local identity and lifecycle role extracted from one event. */ +export interface ConversationMatchResult { + readonly id: string + readonly role: 'start' | 'update' +} + +/** Merge-extensible business values published against one Turn. */ +export interface ConversationTurnDataMap {} + +/** Merge-extensible business values published against one Step. */ +export interface ConversationStepDataMap {} + +/** Stable keyed reader for independently owned Location business values. */ +export interface ConversationLocationDataStore { + /** + * Read one business value without exposing another owner's mutable State. + * @param key - declaration-merged business key. + * @returns latest immutable value, when its owning Context has published one. + */ + get(key: Key): Readonly | undefined +} + +interface ConversationLocationDataValue { + readonly kind: 'turn' | 'step' + readonly turn: number + readonly step?: number + readonly key: string + readonly value: unknown +} + +type RegisteredTurnData = { + [Key in keyof ConversationTurnDataMap & string]: { + readonly kind: 'turn' + readonly turn: number + readonly key: Key + readonly value: ConversationTurnDataMap[Key] + } +}[keyof ConversationTurnDataMap & string] + +type RegisteredStepData = { + [Key in keyof ConversationStepDataMap & string]: { + readonly kind: 'step' + readonly turn: number + readonly step: number + readonly key: Key + readonly value: ConversationStepDataMap[Key] + } +}[keyof ConversationStepDataMap & string] + +/** One Definition-owned value attached to an Engine-owned Turn or Step. */ +export type ConversationLocationData = + [keyof ConversationTurnDataMap | keyof ConversationStepDataMap] extends [never] + ? ConversationLocationDataValue + : RegisteredTurnData | RegisteredStepData + +/** Immutable resolved boundary for one Agent step. */ +export interface StepLocation { + readonly turn: number + readonly step: number + readonly start: SessionEvent<'step/start'> | undefined + readonly end: SessionEvent<'step/end'> | undefined + readonly status: 'open' | 'closed' | 'unknown' + /** Stable reader for Step-scoped business values. */ + readonly data: ConversationLocationDataStore +} + +/** Immutable resolved boundary for one Agent turn. */ +export interface TurnLocation { + readonly turn: number + readonly start: SessionEvent<'turn/start'> | undefined + readonly end: SessionEvent<'turn/end'> | undefined + readonly status: 'open' | 'closed' | 'unknown' + readonly steps: readonly StepLocation[] + /** Stable reader for Turn-scoped business values. */ + readonly data: ConversationLocationDataStore +} + +/** Engine-owned placement of one matched event in the Session hierarchy. */ +export type ConversationLocation = + | { readonly kind: 'session' } + | { readonly kind: 'turn'; readonly turn: TurnLocation } + | { readonly kind: 'step'; readonly turn: TurnLocation; readonly step: StepLocation } + | { readonly kind: 'unresolved' } + +/** One event accepted by a Definition, with its current resolved Location. */ +export interface ConversationMatch extends ConversationEventInput { + readonly role: 'start' | 'update' + readonly location: ConversationLocation +} + +/** Target-neutral identity returned by a business Definition. */ +export interface ConversationViewNode { + readonly key: string + readonly kind: string + readonly id: string + readonly target: string + readonly data: unknown +} + +/** Final Chat render unit produced directly by a business Definition. */ +export interface ChatConversationViewNode extends ConversationViewNode { + readonly target: 'chat' + readonly anchorSeq: number + readonly location: ConversationLocation + readonly visibility: 'visible' | 'hidden' +} + +/** Immutable public view of an assembled business Context. */ +export interface ConversationNodeContext { + readonly key: string + readonly kind: string + readonly id: string + readonly matches: readonly ConversationMatch[] + readonly start: ConversationMatch | undefined + readonly state: State | undefined + readonly current: ReadonlyMap +} + +/** Read-only predecessor returned to a Definition's start function. */ +export interface ConversationPreviousContext { + readonly key: string + readonly kind: string + readonly id: string + readonly startSeq: number + readonly state: Readonly + readonly matches: readonly ConversationMatch[] +} + +/** Strictly-backward Context lookup available while a start is evaluated. */ +export interface ConversationContextReader { + /** + * Find the active Context of `kind` with the greatest start seq below the + * current start event. + * @param kind - Definition kind to query. + * @returns the nearest predecessor, or undefined when absent in the current window. + */ + previous(kind: string): ConversationPreviousContext | undefined +} + +/** Requested cadence for materializing updated business State into view Nodes. */ +export type ConversationPublication = 'none' | 'animation-frame' | 'immediate' + +/** Engine-owned Location data publication phase. */ +export type ConversationLocationDataScope = 'step' | 'turn' + +/** One independently registered business Event-to-Node state machine. */ +export interface ConversationNodeDefinition { + readonly kind: string + /** + * Extract this Definition's stable business identity from one event. + * @param event - raw Session event; no Context or history access is available. + * @returns identity and lifecycle role, or null when unrelated. + */ + match(event: SessionEvent): ConversationMatchResult | null + /** + * Create State from the unique start Match. + * @param context - complete evidence currently collected for the Context. + * @param match - the start Match. + * @param reader - strictly-backward read-only Context lookup. + * @returns the State adopted by the engine. + */ + start( + context: ConversationNodeContext, + match: ConversationMatch, + reader: ConversationContextReader, + ): State + /** + * Apply one post-start update Match. + * @param context - Context with its current State. + * @param match - update Match in ascending log order. + * @returns the State adopted by the engine. + */ + update( + context: ConversationNodeContext & { readonly state: State }, + match: ConversationMatch, + ): State + /** + * Select publication cadence for one accepted Match. + * @param match - accepted Match. + * @returns requested cadence; omission defaults to immediate. + */ + publication?(match: ConversationMatch): ConversationPublication + /** + * Publish this Definition's read-only business value for one Location phase. + * The Engine evaluates every Definition first for Step and then for Turn, + * owns replacement/removal, and rejects another Context trying to publish + * the same Location key. + * @param context - latest complete Context. + * @param scope - Location hierarchy level currently being materialized. + * @returns current Location value, or null while unavailable. + */ + buildLocationData?( + context: ConversationNodeContext, + scope: ConversationLocationDataScope, + ): ConversationLocationData | null + /** + * Materialize one final Node for a registered view target. + * @param context - latest complete Context. + * @param target - registered view target such as `chat`. + * @returns final Node, or null when this Context is not currently visible. + */ + buildViewNode( + context: ConversationNodeContext, + target: string, + ): ConversationViewNode | null +} + +/** Reference-stable Turn/Step facts published beside view Nodes. */ +export interface ConversationTimelineSnapshot { + readonly turnOrder: readonly number[] + readonly turns: ReadonlyMap +} + +/** Per-Session incremental builder for one view target. */ +export interface ConversationViewBuilder { + readonly empty: Snapshot + /** + * Replace the low-frequency complete materialized Node set. + * @param input - complete Nodes and current timeline. + * @returns next view snapshot. + */ + replace(input: { + readonly nodes: readonly Node[] + readonly timeline: ConversationTimelineSnapshot + }): Snapshot + /** + * Apply only Nodes whose materialized values changed in this transaction. + * @param input - changed Nodes and current timeline. + * @returns next view snapshot. + */ + apply(input: { + readonly upserts: readonly Node[] + readonly timeline: ConversationTimelineSnapshot + }): Snapshot +} + +/** Registry contribution that creates one isolated view builder per Session. */ +export interface ConversationViewDefinition { + readonly target: string + /** @returns a new Session-owned incremental builder. */ + create(): ConversationViewBuilder +} + +/** + * Build a stable collision-free key for one Definition-local business identity. + * @param kind - Definition kind. + * @param id - Definition-local business identity. + * @returns engine-owned Context key. + */ +export function conversationContextKey(kind: string, id: string): string { + return `${kind.length}:${kind}${id}` +} diff --git a/packages/client/runtime/src/client/conversation/definition-registry.ts b/packages/client/runtime/src/client/conversation/definition-registry.ts new file mode 100644 index 0000000000..d43f494e1a --- /dev/null +++ b/packages/client/runtime/src/client/conversation/definition-registry.ts @@ -0,0 +1,60 @@ +import { Service } from 'cordis' + +/** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */ +export abstract class ConversationDefinitionRegistry extends Service { + protected readonly definitions = new Map() + private listeners = new Set<() => void>() + private cached: readonly Definition[] = [] + + /** + * Return reference-stable Definitions in registration order. + * @returns current Definitions. + */ + entries(): readonly Definition[] { + return this.cached + } + + /** + * Observe low-frequency registry changes. + * @param listener - synchronous invalidation callback. + * @returns unsubscribe callback. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Register one uniquely keyed Definition for the caller's lifetime. + * @param key - registry-local unique key. + * @param definition - contributed Definition. + * @param duplicateMessage - error raised when the key is already owned. + * @param effectName - Cordis effect diagnostic label. + * @returns idempotent disposer. + */ + protected registerDefinition( + key: string, + definition: Definition, + duplicateMessage: string, + effectName: string, + ): () => void { + if (this.definitions.has(key)) throw new Error(duplicateMessage) + const owner = this.ctx + const dispose = owner.effect(() => { + this.definitions.set(key, definition) + this.refresh() + return () => { + if (this.definitions.get(key) !== definition) return + this.definitions.delete(key) + this.refresh() + } + }, effectName) + return () => { void dispose() } + } + + /** Refresh cached entries and synchronously invalidate subscribers. */ + protected refresh(): void { + this.cached = [...this.definitions.values()] + for (const listener of this.listeners) listener() + } +} diff --git a/packages/client/runtime/src/client/conversation/event-registry.ts b/packages/client/runtime/src/client/conversation/event-registry.ts new file mode 100644 index 0000000000..6935ed1741 --- /dev/null +++ b/packages/client/runtime/src/client/conversation/event-registry.ts @@ -0,0 +1,56 @@ +import type { Context } from 'cordis' +import type { ConversationNodeDefinition } from '../contract/conversation.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' + +/** Runtime registry of independently owned Conversation business Definitions. */ +export class ConversationEventRegistry extends ConversationDefinitionRegistry { + private fallback: ConversationNodeDefinition | undefined + + /** @param ctx - owning Client Runtime context. */ + constructor(ctx: Context) { + super(ctx, 'conversationEvents') + } + + /** + * Register a uniquely named business Definition for the caller's lifetime. + * @param definition - Definition contribution. + * @returns idempotent disposer. + */ + register(definition: ConversationNodeDefinition): () => void { + return this.registerDefinition( + definition.kind, + definition, + `conversation Definition "${definition.kind}" is already registered`, + `conversationEvents.register(${JSON.stringify(definition.kind)})`, + ) + } + + /** + * Register the sole fallback used only when no ordinary Definition matches. + * @param definition - fallback Definition. + * @returns idempotent disposer. + */ + registerFallback(definition: ConversationNodeDefinition): () => void { + if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered') + const owner = this.ctx + const dispose = owner.effect(() => { + this.fallback = definition + this.refresh() + return () => { + if (this.fallback !== definition) return + this.fallback = undefined + this.refresh() + } + }, `conversationEvents.registerFallback(${JSON.stringify(definition.kind)})`) + return () => { void dispose() } + } + + /** + * Return the current unmatched-event fallback. + * @returns installed fallback, when present. + */ + fallbackEntry(): ConversationNodeDefinition | undefined { + return this.fallback + } + +} diff --git a/packages/client/runtime/src/client/conversation/view-registry.ts b/packages/client/runtime/src/client/conversation/view-registry.ts new file mode 100644 index 0000000000..1e2e53e141 --- /dev/null +++ b/packages/client/runtime/src/client/conversation/view-registry.ts @@ -0,0 +1,26 @@ +import type { Context } from 'cordis' +import type { ConversationViewDefinition } from '../contract/conversation.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' + +/** Runtime registry of per-target Conversation snapshot builders. */ +export class ConversationViewRegistry extends ConversationDefinitionRegistry { + + /** @param ctx - owning Client Runtime context. */ + constructor(ctx: Context) { + super(ctx, 'conversationViews') + } + + /** + * Register a uniquely named view builder factory for the caller's lifetime. + * @param definition - target builder contribution. + * @returns idempotent disposer. + */ + register(definition: ConversationViewDefinition): () => void { + return this.registerDefinition( + definition.target, + definition, + `conversation view target "${definition.target}" is already registered`, + `conversationViews.register(${JSON.stringify(definition.target)})`, + ) + } +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b7226c0085..fe1ce8f7b0 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -10,8 +10,27 @@ import { SessionHistoryService } from './session-history/service.ts' import { WorkspacesService } from './workspaces/service.ts' import type { ConversationSnapshot } from './sessions/conversation.ts' import type { UseProjection } from './sessions/projection-store.ts' +import { ConversationEventRegistry } from './conversation/event-registry.ts' +import { ConversationViewRegistry } from './conversation/view-registry.ts' + +export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' export { SlotsService } from './slots.ts' +export { ConversationEventRegistry } from './conversation/event-registry.ts' +export { ConversationViewRegistry } from './conversation/view-registry.ts' +export { ConversationNodeAssembler } from './sessions/conversation-assembler.ts' +export { ConversationLocationIndex } from './sessions/conversation-location-index.ts' +export { conversationContextKey } from './contract/conversation.ts' +export type { + ChatConversationViewNode, ConversationContextReader, ConversationEventInput, + ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore, + ConversationStepDataMap, + ConversationLocation, ConversationMatch, ConversationMatchResult, + ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, + ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder, + ConversationViewDefinition, ConversationViewNode, StepLocation, TurnLocation, +} from './contract/conversation.ts' +export type { ConversationRuntime } from './sessions/conversation-assembler.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { SessionHistoryService } from './session-history/service.ts' @@ -49,11 +68,17 @@ export type { } from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, - AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase, + AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, + CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, + LegacyConversationSlice, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' +export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './sessions/conversation.ts' +export { emptyAssistantBlock } from './sessions/partial.ts' +export { isTokenDelta } from './sessions/assistant-timing.ts' +export { contextForm, contextProvenance } from './sessions/context-provenance.ts' +export { displayFailureMessage } from './sessions/failure-display.ts' export type { ConversationContext, ConversationContextOriginKind, } from './sessions/conversation-context.ts' @@ -165,6 +190,10 @@ declare module 'cordis' { } interface Context { slots: import('./slots.ts').SlotsService + /** Event-to-business-Context Definition registry. */ + conversationEvents: import('./conversation/event-registry.ts').ConversationEventRegistry + /** Per-target Conversation snapshot builder registry. */ + conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry /** The outward face only; the concrete service stays inside the runtime. */ sessions: import('./contract/sessions.ts').ISessions /** Read-only history sources isolated from Chat sessions and workspace state. */ @@ -182,8 +211,12 @@ export const inject = ['connection', 'typert'] */ export function apply(ctx: Context): void { ctx.plugin(SlotsService) + const conversation = { + events: new ConversationEventRegistry(ctx), + views: new ConversationViewRegistry(ctx), + } const connection = ctx.get('connection') as ConnectionHandle - const sessions = new SessionsService(ctx, connection.api) + const sessions = new SessionsService(ctx, connection.api, conversation) ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), }) diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/runtime/src/client/sessions/assistant-timing.ts index 021c679d04..3c54c9c36d 100644 --- a/packages/client/runtime/src/client/sessions/assistant-timing.ts +++ b/packages/client/runtime/src/client/sessions/assistant-timing.ts @@ -1,7 +1,6 @@ -// Shared assistant step-timing fold: both transcript projections (the live -// window adapter and the trajectory history fold) derive AssistantTiming from -// the same step/start -> first token delta -> assistant/message sequence, so -// the derivation lives once here instead of drifting per projection. +// Shared assistant step-timing fold: Chat Definitions and the Trajectory +// history fold derive AssistantTiming from the same step/start -> first token +// delta -> assistant/message sequence. import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantTiming } from './conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts new file mode 100644 index 0000000000..3b6fbd6266 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -0,0 +1,798 @@ +import type { + ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch, + ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, + ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder, + ConversationViewDefinition, ConversationViewNode, +} from '../contract/conversation.ts' +import { conversationContextKey } from '../contract/conversation.ts' +import { + ConversationLocationIndex, type ConversationLocationDataChange, +} from './conversation-location-index.ts' + +interface Dependency { + readonly kind: string + readonly key: string | undefined + readonly revision: number | undefined + readonly windowGap: boolean +} + +interface InternalContext { + readonly key: string + readonly kind: string + readonly id: string + readonly definition: ConversationNodeDefinition + startSeq: number | undefined + start: ConversationMatch | undefined + matches: ConversationMatch[] + state: unknown + revision: number + readonly current: Map + readonly locationData: Record + dependencies: Map +} + +interface PendingMatch { + readonly definition: ConversationNodeDefinition + readonly id: string + readonly match: ConversationMatch +} + +interface ViewState { + readonly target: string + readonly builder: ConversationViewBuilder + snapshot: unknown +} + +const PUBLICATION_RANK: Record = { + none: 0, + 'animation-frame': 1, + immediate: 2, +} + +const LOCATION_DATA_SCOPES: readonly ConversationLocationDataScope[] = ['step', 'turn'] + +function emptyLocationData(): Record { + return { step: null, turn: null } +} + +function maximumPublication( + left: ConversationPublication, + right: ConversationPublication, +): ConversationPublication { + return PUBLICATION_RANK[left] >= PUBLICATION_RANK[right] ? left : right +} + +function startSeq(context: InternalContext): number | undefined { + return context.startSeq +} + +function insertionIndex(contexts: readonly InternalContext[], seq: number): number { + let low = 0 + let high = contexts.length + while (low < high) { + const middle = low + Math.floor((high - low) / 2) + const candidate = contexts[middle] + if (candidate !== undefined && (candidate.startSeq as number) < seq) low = middle + 1 + else high = middle + } + return low +} + +function contextSnapshot(context: InternalContext): ConversationNodeContext { + return { + key: context.key, + kind: context.kind, + id: context.id, + matches: context.matches, + start: context.start, + state: context.state as State | undefined, + current: context.current, + } +} + +function mergeMatches( + key: string, + additions: readonly ConversationMatch[], + existing: readonly ConversationMatch[], +): ConversationMatch[] { + const merged: ConversationMatch[] = [] + let added = 0 + let current = 0 + while (added < additions.length || current < existing.length) { + const left = additions[added] + const right = existing[current] + if (left !== undefined && right !== undefined && left.event.seq === right.event.seq) { + throw new Error(`conversation Context ${key} received duplicate Match ${left.event.seq}`) + } + if (right === undefined || (left !== undefined && left.event.seq < right.event.seq)) { + merged.push(left as ConversationMatch) + added++ + } else { + merged.push(right) + current++ + } + } + return merged +} + +/** Event Registry subset consumed by a Session-owned Assembler. */ +export interface ConversationEventDefinitions { + /** @returns ordinary Definitions in registration order. */ + entries(): readonly ConversationNodeDefinition[] + /** @returns unmatched-event fallback, when registered. */ + fallbackEntry(): ConversationNodeDefinition | undefined +} + +/** View Registry subset consumed by a Session-owned Assembler. */ +export interface ConversationViewDefinitions { + /** @returns view builder factories in registration order. */ + entries(): readonly ConversationViewDefinition[] +} + +/** + * Session-owned incremental engine that assembles business Contexts from a + * contiguous Event window and materializes registered view snapshots. + */ +export class ConversationNodeAssembler { + private readonly contexts = new Map() + private readonly contextsByKind = new Map() + private readonly contextsBySeq = new Map>() + private readonly inputs = new Map() + private readonly locationIndex = new ConversationLocationIndex() + private readonly dirty = new Set() + private readonly revised = new Set() + private readonly dependents = new Map>() + private readonly views = new Map() + private hasMore = false + private replacePending = true + private timelineDirty = true + + /** + * @param eventDefinitions - live Event Definition registry. + * @param viewDefinitions - live view builder registry. + */ + constructor( + private readonly eventDefinitions: ConversationEventDefinitions, + private readonly viewDefinitions: ConversationViewDefinitions, + ) { + this.resetViewBuilders() + } + + /** + * Replace the complete loaded window after open, resync, or gap repair. + * @param entries - complete contiguous window. + * @param hasMore - whether older history remains outside the window. + * @returns immediate publication request. + */ + replaceWindow(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication { + this.contexts.clear() + this.contextsByKind.clear() + this.contextsBySeq.clear() + this.inputs.clear() + this.dirty.clear() + this.revised.clear() + this.dependents.clear() + this.hasMore = hasMore + const sorted = [...entries].sort((left, right) => left.event.seq - right.event.seq) + for (const entry of sorted) this.inputs.set(entry.event.seq, entry) + this.locationIndex.rebuild(sorted) + this.timelineDirty = true + for (const entry of sorted) this.matchInput(entry) + this.replayDependencies() + this.revised.clear() + for (const context of this.contexts.values()) this.dirty.add(context) + this.replacePending = true + return 'immediate' + } + + /** + * Add one contiguous live tail event without scanning existing Contexts. + * @param input - appended Event and optional wire view. + * @returns highest requested publication cadence. + */ + append(input: ConversationEventInput): ConversationPublication { + if (this.inputs.has(input.event.seq)) return 'none' + this.revised.clear() + this.inputs.set(input.event.seq, input) + let publication: ConversationPublication = 'none' + if (isLocationBoundary(input.event.type)) { + const previousTimeline = this.locationIndex.snapshot() + const changed = this.locationIndex.appendBoundary(input.event) + if (this.locationIndex.snapshot() !== previousTimeline) { + this.timelineDirty = true + publication = 'immediate' + } + this.replayContexts(this.refreshMatchLocations(changed)) + if (changed.size > 0) publication = 'immediate' + } else { + this.locationIndex.appendNonBoundary(input.event) + } + publication = maximumPublication(publication, this.matchInput(input)) + if (this.replayRevisedDependents()) publication = 'immediate' + this.revised.clear() + return publication + } + + /** + * Add an older page while preserving existing Context and view identities. + * @param entries - newly loaded older Events. + * @param hasMore - whether history still precedes the expanded window. + * @returns highest requested publication cadence. + */ + prepend(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication { + this.revised.clear() + let publication: ConversationPublication = 'none' + const previousHasMore = this.hasMore + const fresh = entries + .filter(entry => !this.inputs.has(entry.event.seq)) + .sort((left, right) => left.event.seq - right.event.seq) + for (const entry of fresh) this.inputs.set(entry.event.seq, entry) + this.hasMore = hasMore + const previousTimeline = this.locationIndex.snapshot() + const changedLocations = this.locationIndex.rebuild(this.sortedInputs()) + if (this.locationIndex.snapshot() !== previousTimeline) this.timelineDirty = true + const affected = this.refreshMatchLocations(changedLocations) + const pending = new Map() + for (const entry of fresh) { + publication = maximumPublication(publication, this.collectInput(entry, pending)) + } + this.applyPendingMatches(pending, affected) + this.replayContexts(affected) + if ((fresh.length > 0 || previousHasMore !== hasMore) && this.replayDependencies()) { + publication = 'immediate' + } + if (changedLocations.size > 0) publication = 'immediate' + this.revised.clear() + return publication + } + + /** + * Rebuild against the current Registry set after a low-frequency plugin change. + * @returns immediate publication request. + */ + rebuildRegistry(): ConversationPublication { + this.resetViewBuilders() + return this.replaceWindow(this.sortedInputs(), this.hasMore) + } + + /** + * Materialize dirty Contexts and advance every registered view builder. + * @returns whether any view snapshot was rebuilt or incrementally applied. + */ + flush(): boolean { + if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false + if (this.replacePending) { + this.replaceLocationData() + const allByTarget = new Map() + for (const target of this.views.keys()) allByTarget.set(target, []) + for (const context of this.contexts.values()) { + for (const target of this.views.keys()) { + const node = this.buildNode(context, target) + context.current.set(target, node) + if (node !== null) allByTarget.get(target)?.push(node) + } + } + for (const view of this.views.values()) { + view.snapshot = view.builder.replace({ + nodes: allByTarget.get(view.target) ?? [], + timeline: this.locationIndex.snapshot(), + }) + } + this.replacePending = false + this.dirty.clear() + this.timelineDirty = false + return true + } + + const upsertsByTarget = new Map() + for (const target of this.views.keys()) upsertsByTarget.set(target, []) + if (this.applyDirtyLocationData()) this.timelineDirty = true + for (const context of this.dirty) { + for (const target of this.views.keys()) { + const previous = context.current.get(target) ?? null + const node = this.buildNode(context, target) + if (node === null && previous !== null) { + throw new Error( + `conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`, + ) + } + context.current.set(target, node) + if (node !== null) upsertsByTarget.get(target)?.push(node) + } + } + this.dirty.clear() + const timelineDirty = this.timelineDirty + this.timelineDirty = false + for (const view of this.views.values()) { + const upserts = upsertsByTarget.get(view.target) ?? [] + if (upserts.length === 0 && !timelineDirty) continue + view.snapshot = view.builder.apply({ + upserts, + timeline: this.locationIndex.snapshot(), + }) + } + return true + } + + /** + * Read the latest snapshot of a registered target. + * @param target - registered view target. + * @returns target snapshot, or undefined when no builder is registered. + */ + snapshot(target: string): unknown { + return this.views.get(target)?.snapshot + } + + private sortedInputs(): ConversationEventInput[] { + return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq) + } + + private matchInput(input: ConversationEventInput): ConversationPublication { + return this.dispatchInput(input, (definition, id, role) => + this.acceptMatch(definition, id, role, input)) + } + + private collectInput( + input: ConversationEventInput, + pending: Map, + ): ConversationPublication { + return this.dispatchInput(input, (definition, id, role) => { + const key = conversationContextKey(definition.kind, id) + const match: ConversationMatch = { + ...input, + role, + location: this.locationIndex.locationOf(input.event), + } + const matches = pending.get(key) ?? [] + matches.push({ definition, id, match }) + pending.set(key, matches) + return definition.publication?.(match) ?? 'immediate' + }) + } + + private dispatchInput( + input: ConversationEventInput, + accept: ( + definition: ConversationNodeDefinition, + id: string, + role: ConversationMatch['role'], + ) => ConversationPublication, + ): ConversationPublication { + let matched = false + let publication: ConversationPublication = 'none' + for (const definition of this.eventDefinitions.entries()) { + const result = definition.match(input.event) + if (result === null) continue + matched = true + publication = maximumPublication(publication, accept(definition, result.id, result.role)) + } + if (!matched) { + const fallback = this.eventDefinitions.fallbackEntry() + const result = fallback?.match(input.event) ?? null + if (fallback !== undefined && result !== null) { + publication = maximumPublication(publication, accept(fallback, result.id, result.role)) + } + } + return publication + } + + private acceptMatch( + definition: ConversationNodeDefinition, + id: string, + role: ConversationMatch['role'], + input: ConversationEventInput, + ): ConversationPublication { + const key = conversationContextKey(definition.kind, id) + let context = this.contexts.get(key) + if (role === 'start' && context?.start !== undefined) { + throw new Error(`conversation Context ${key} received more than one start Match`) + } + if (context === undefined) { + context = { + key, + kind: definition.kind, + id, + definition, + startSeq: undefined, + start: undefined, + matches: [], + state: undefined, + revision: 0, + current: new Map(), + locationData: emptyLocationData(), + dependencies: new Map(), + } + this.contexts.set(key, context) + } + const match: ConversationMatch = { + ...input, + role, + location: this.locationIndex.locationOf(input.event), + } + const previous = context.matches.at(-1) + if (previous !== undefined && previous.event.seq >= input.event.seq) { + throw new Error(`conversation Context ${key} received non-appended Match ${input.event.seq}`) + } + if (role === 'start' && context.matches.length > 0) { + throw new Error(`conversation Context ${key} received an update before its start Match`) + } + context.matches.push(match) + if (role === 'start') { + context.startSeq = input.event.seq + context.start = match + this.indexStartedContext(context) + } + const owners = this.contextsBySeq.get(input.event.seq) ?? new Set() + owners.add(context) + this.contextsBySeq.set(input.event.seq, owners) + + if (role === 'start') { + this.replayContext(context) + } else if (context.state !== undefined) { + const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } + context.state = requireState(definition, 'update', definition.update(typed, match)) + context.revision++ + this.revised.add(context) + } + this.dirty.add(context) + return definition.publication?.(match) ?? 'immediate' + } + + private applyPendingMatches( + pending: ReadonlyMap, + affected: Set, + ): void { + const startsByKind = new Map() + for (const [key, entries] of pending) { + const first = entries[0] + if (first === undefined) continue + let context = this.contexts.get(key) + if (context === undefined) { + context = { + key, + kind: first.definition.kind, + id: first.id, + definition: first.definition, + startSeq: undefined, + start: undefined, + matches: [], + state: undefined, + revision: 0, + current: new Map(), + locationData: emptyLocationData(), + dependencies: new Map(), + } + this.contexts.set(key, context) + } + let discoveredStart: ConversationMatch | undefined + const additions = entries + .map((entry) => { + if (entry.definition !== context.definition || entry.id !== context.id) { + throw new Error(`conversation Context ${key} received inconsistent Definition identity`) + } + if (entry.match.role === 'start') { + if (discoveredStart !== undefined || context.start !== undefined) { + throw new Error(`conversation Context ${key} received more than one start Match`) + } + discoveredStart = entry.match + } + const owners = this.contextsBySeq.get(entry.match.event.seq) ?? new Set() + owners.add(context) + this.contextsBySeq.set(entry.match.event.seq, owners) + return entry.match + }) + .sort((left, right) => left.event.seq - right.event.seq) + context.matches = mergeMatches(context.key, additions, context.matches) + if (discoveredStart !== undefined) { + context.start = discoveredStart + context.startSeq = discoveredStart.event.seq + const starts = startsByKind.get(context.kind) ?? [] + starts.push(context) + startsByKind.set(context.kind, starts) + } + if (context.start !== undefined && context.matches[0] !== context.start) { + throw new Error(`conversation Context ${context.key} received an update before its start Match`) + } + affected.add(context) + this.dirty.add(context) + } + for (const [kind, contexts] of startsByKind) this.indexStartedContexts(kind, contexts) + } + + private replayContexts(contexts: ReadonlySet): void { + const ordered = [...contexts].sort((left, right) => + (left.startSeq ?? Number.POSITIVE_INFINITY) - (right.startSeq ?? Number.POSITIVE_INFINITY)) + for (const context of ordered) { + if (context.start === undefined) { + context.state = undefined + this.dirty.add(context) + continue + } + this.replayContext(context) + } + } + + private replayContext(context: InternalContext): void { + const start = context.start + if (start === undefined) { + context.state = undefined + return + } + if (context.matches[0] !== start) { + throw new Error(`conversation Context ${context.key} received an update before its start Match`) + } + const dependencies = new Map() + const reader = this.readerFor(start.event.seq, dependencies) + context.state = undefined + context.state = requireState( + context.definition, + 'start', + context.definition.start(contextSnapshot(context), start, reader), + ) + this.replaceDependencies(context, dependencies) + for (let index = 1; index < context.matches.length; index++) { + const match = context.matches[index] + if (match === undefined || match.role !== 'update') continue + const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } + context.state = requireState( + context.definition, + 'update', + context.definition.update(typed, match), + ) + } + context.revision++ + this.revised.add(context) + this.dirty.add(context) + } + + private replaceDependencies(context: InternalContext, dependencies: Map): void { + for (const dependency of context.dependencies.values()) { + if (dependency.key === undefined) continue + const current = this.dependents.get(dependency.key) + current?.delete(context) + if (current?.size === 0) this.dependents.delete(dependency.key) + } + context.dependencies = dependencies + for (const dependency of dependencies.values()) { + if (dependency.key === undefined) continue + const current = this.dependents.get(dependency.key) ?? new Set() + current.add(context) + this.dependents.set(dependency.key, current) + } + } + + private replayRevisedDependents(): boolean { + const pending = [...this.revised] + const replayed = new Set() + for (let index = 0; index < pending.length; index++) { + const dependency = pending[index] + if (dependency === undefined) continue + for (const dependent of this.dependents.get(dependency.key) ?? []) { + if (replayed.has(dependent)) continue + replayed.add(dependent) + this.replayContext(dependent) + pending.push(dependent) + } + } + return replayed.size > 0 + } + + private readerFor( + beforeSeq: number, + dependencies: Map, + ): ConversationContextReader { + return { + previous: (kind: string): ConversationPreviousContext | undefined => { + const predecessor = this.previousContext(kind, beforeSeq) + dependencies.set(kind, { + kind, + key: predecessor?.key, + revision: predecessor?.revision, + windowGap: predecessor === undefined && this.hasMore, + }) + if (predecessor?.state === undefined) return undefined + const seq = startSeq(predecessor) + if (seq === undefined) return undefined + return { + key: predecessor.key, + kind: predecessor.kind, + id: predecessor.id, + startSeq: seq, + state: predecessor.state as Readonly, + matches: predecessor.matches, + } + }, + } + } + + private previousContext(kind: string, beforeSeq: number): InternalContext | undefined { + const candidates = this.contextsByKind.get(kind) ?? [] + const indexBefore = insertionIndex(candidates, beforeSeq) + for (let index = indexBefore - 1; index >= 0; index--) { + const candidate = candidates[index] + if (candidate?.state !== undefined) return candidate + } + return undefined + } + + /** Insert one newly discovered start into its Definition's ordered predecessor index. */ + private indexStartedContext(context: InternalContext): void { + const seq = context.startSeq + if (seq === undefined) return + const candidates = this.contextsByKind.get(context.kind) ?? [] + const previous = candidates.at(-1) + if (previous === undefined || (previous.startSeq as number) < seq) candidates.push(context) + else candidates.splice(insertionIndex(candidates, seq), 0, context) + this.contextsByKind.set(context.kind, candidates) + } + + private indexStartedContexts(kind: string, additions: readonly InternalContext[]): void { + if (additions.length === 0) return + const sorted = [...additions].sort((left, right) => + (left.startSeq as number) - (right.startSeq as number)) + const existing = this.contextsByKind.get(kind) ?? [] + const merged: InternalContext[] = [] + let before = 0 + let added = 0 + while (before < existing.length || added < sorted.length) { + const left = existing[before] + const right = sorted[added] + if (right === undefined || (left !== undefined && (left.startSeq as number) < (right.startSeq as number))) { + merged.push(left as InternalContext) + before++ + } else { + merged.push(right) + added++ + } + } + this.contextsByKind.set(kind, merged) + } + + private replayDependencies(): boolean { + let replayed = false + const ordered = [...this.contexts.values()] + .filter(context => startSeq(context) !== undefined) + .sort((left, right) => (startSeq(left) as number) - (startSeq(right) as number)) + for (const context of ordered) { + if (context.state === undefined || context.dependencies.size === 0) continue + const before = startSeq(context) + if (before === undefined) continue + let changed = false + for (const dependency of context.dependencies.values()) { + const current = this.previousContext(dependency.kind, before) + const windowGap = current === undefined && this.hasMore + if (current?.key !== dependency.key + || current?.revision !== dependency.revision + || windowGap !== dependency.windowGap) { + changed = true + break + } + } + if (changed) { + this.replayContext(context) + replayed = true + } + } + return replayed + } + + private refreshMatchLocations(changedSeqs: ReadonlySet): Set { + const affected = new Set() + if (changedSeqs.size === 0) return affected + for (const seq of changedSeqs) { + for (const context of this.contextsBySeq.get(seq) ?? []) affected.add(context) + } + for (const context of affected) { + let start = context.start + const matches = context.matches.map((match): ConversationMatch => { + if (!changedSeqs.has(match.event.seq)) return match + const refreshed = { ...match, location: this.locationIndex.locationOf(match.event) } + if (match === start) start = refreshed + return refreshed + }) + context.matches = matches + context.start = start + } + return affected + } + + private buildNode(context: InternalContext, target: string): ConversationViewNode | null { + const node = context.definition.buildViewNode(contextSnapshot(context), target) + if (node === null) return null + if (node.key !== context.key) { + throw new Error(`conversation Definition "${context.kind}" returned unstable key "${node.key}"; expected "${context.key}"`) + } + if (node.target !== target) { + throw new Error(`conversation Definition "${context.kind}" returned target "${node.target}" while building "${target}"`) + } + return node + } + + private buildLocationData( + context: InternalContext, + scope: ConversationLocationDataScope, + ): ConversationLocationData | null { + const build = context.definition.buildLocationData + if (build === undefined) return null + const data = build(contextSnapshot(context), scope) + if (data === null) return null + if (data.kind !== scope) { + throw new Error( + `conversation Definition "${context.kind}" published ${data.kind} data through its ${scope} scope`, + ) + } + if (data.key !== context.kind) { + throw new Error( + `conversation Definition "${context.kind}" published Location data key "${data.key}"; expected its owned kind`, + ) + } + if (!Number.isSafeInteger(data.turn) || data.turn < 0) { + throw new Error(`conversation Definition "${context.kind}" published invalid turn ${data.turn}`) + } + if (data.kind === 'step' && (!Number.isSafeInteger(data.step) || (data.step as number) < 0)) { + throw new Error(`conversation Definition "${context.kind}" published invalid step ${String(data.step)}`) + } + return data + } + + private replaceLocationData(): void { + const entries: { owner: string; data: ConversationLocationData }[] = [] + for (const scope of LOCATION_DATA_SCOPES) { + for (const context of this.contexts.values()) { + const data = this.buildLocationData(context, scope) + context.locationData[scope] = data + if (data !== null) entries.push({ owner: context.key, data }) + } + this.locationIndex.replaceData(entries) + } + } + + private applyDirtyLocationData(): boolean { + let changed = false + for (const scope of LOCATION_DATA_SCOPES) { + const changes: ConversationLocationDataChange[] = [] + for (const context of this.dirty) { + const previous = context.locationData[scope] + const next = this.buildLocationData(context, scope) + context.locationData[scope] = next + if (previous !== next) changes.push({ owner: context.key, previous, next }) + } + changed = this.locationIndex.applyData(changes) || changed + } + return changed + } + + private resetViewBuilders(): void { + this.views.clear() + for (const definition of this.viewDefinitions.entries()) { + const builder = definition.create() + this.views.set(definition.target, { + target: definition.target, + builder, + snapshot: builder.empty, + }) + } + this.replacePending = true + } +} + +function isLocationBoundary(type: string): boolean { + return type === 'turn/start' || type === 'turn/end' || type === 'step/start' || type === 'step/end' +} + +function requireState( + definition: ConversationNodeDefinition, + phase: 'start' | 'update', + state: unknown, +): unknown { + if (state === undefined) { + throw new Error(`conversation Definition "${definition.kind}" returned undefined from ${phase}()`) + } + return state +} + +/** Structural registry pair accepted by Session and SessionManager. */ +export interface ConversationRuntime { + readonly events: ConversationEventDefinitions & { subscribe(listener: () => void): () => void } + readonly views: ConversationViewDefinitions & { subscribe(listener: () => void): () => void } +} diff --git a/packages/client/runtime/src/client/sessions/conversation-location-index.ts b/packages/client/runtime/src/client/sessions/conversation-location-index.ts new file mode 100644 index 0000000000..20fe3efdff --- /dev/null +++ b/packages/client/runtime/src/client/sessions/conversation-location-index.ts @@ -0,0 +1,508 @@ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { + ConversationEventInput, ConversationLocation, ConversationLocationData, + ConversationLocationDataStore, ConversationStepDataMap, ConversationTimelineSnapshot, + ConversationTurnDataMap, StepLocation, TurnLocation, +} from '../contract/conversation.ts' + +interface OwnedLocationData { + readonly owner: string + readonly value: unknown +} + +/** One Context's previous and next Location-data publication. */ +export interface ConversationLocationDataChange { + readonly owner: string + readonly previous: ConversationLocationData | null + readonly next: ConversationLocationData | null +} + +class MutableLocationDataStore { + private entries = new Map() + + get(key: Key): unknown { + return this.entries.get(key)?.value + } + + remove(owner: string, key: string): boolean { + const current = this.entries.get(key) + if (current?.owner !== owner) return false + this.entries.delete(key) + return true + } + + set(owner: string, key: string, value: unknown): boolean { + const current = this.entries.get(key) + if (current !== undefined && current.owner !== owner) { + throw new Error(`conversation Location data "${key}" is already owned by ${current.owner}`) + } + if (current?.value === value) return false + this.entries.set(key, { owner, value }) + return true + } + + replace(entries: ReadonlyMap): boolean { + let changed = this.entries.size !== entries.size + if (!changed) { + for (const [key, value] of entries) { + const current = this.entries.get(key) + if (current?.owner !== value.owner || current.value !== value.value) { + changed = true + break + } + } + } + if (changed) this.entries = new Map(entries) + return changed + } +} + +interface Coordinates { + readonly turn?: number + readonly step?: number + readonly session?: true +} + +interface StepDraft { + readonly turn: number + readonly step: number + firstSeq: number + start?: SessionEvent<'step/start'> + end?: SessionEvent<'step/end'> +} + +interface TurnDraft { + readonly turn: number + firstSeq: number + start?: SessionEvent<'turn/start'> + end?: SessionEvent<'turn/end'> + readonly steps: Map +} + +const SESSION_LOCATION = { kind: 'session' } as const +const UNRESOLVED_LOCATION = { kind: 'unresolved' } as const + +function payloadCoordinates(event: SessionEvent): Coordinates { + const data = event.data as unknown as { turn?: unknown; step?: unknown } + if (data.turn === null) return { session: true } + const turn = Number.isSafeInteger(data.turn) && (data.turn as number) >= 0 + ? data.turn as number + : undefined + const step = Number.isSafeInteger(data.step) && (data.step as number) >= 0 + ? data.step as number + : undefined + return { ...turn === undefined ? {} : { turn }, ...step === undefined ? {} : { step } } +} + +function sameReferences(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function sameStep(left: StepLocation | undefined, right: StepLocation): boolean { + return left !== undefined + && left.start === right.start && left.end === right.end && left.status === right.status + && left.data === right.data +} + +function sameTurn(left: TurnLocation | undefined, right: TurnLocation): boolean { + return left !== undefined + && left.start === right.start && left.end === right.end && left.status === right.status + && left.data === right.data && sameReferences(left.steps, right.steps) +} + +function sameLocation(left: ConversationLocation | undefined, right: ConversationLocation | undefined): boolean { + if (left === undefined || right === undefined || left.kind !== right.kind) return left === right + if (left.kind === 'session' || left.kind === 'unresolved') return true + if (right.kind === 'session' || right.kind === 'unresolved') return false + if (left.kind === 'turn' || right.kind === 'turn') { + return left.kind === 'turn' && right.kind === 'turn' && left.turn === right.turn + } + return left.turn === right.turn && left.step === right.step +} + +/** Session-owned Turn/Step timeline and event-to-Location index. */ +export class ConversationLocationIndex { + private coordinates = new Map() + private locations = new Map() + private seqsByTurn = new Map>() + private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() } + private readonly turnDataStores = new Map() + private readonly stepDataStores = new Map() + private currentTurn: number | undefined + private currentStep: number | undefined + + /** + * Return the current reference-stable timeline. + * @returns current timeline snapshot. + */ + snapshot(): ConversationTimelineSnapshot { + return this.timeline + } + + /** Replace all Definition-owned Location values while preserving reader identities. */ + replaceData(entries: readonly { readonly owner: string; readonly data: ConversationLocationData }[]): boolean { + const turns = new Map>() + const steps = new Map>() + for (const { owner, data } of entries) { + const values = data.kind === 'turn' + ? turns.get(data.turn) ?? new Map() + : steps.get(stepDataKey(data.turn, requireStep(data))) ?? new Map() + const current = values.get(data.key) + if (current !== undefined && current.owner !== owner) { + throw new Error(`conversation Location data "${data.key}" is already owned by ${current.owner}`) + } + values.set(data.key, { owner, value: data.value }) + if (data.kind === 'turn') turns.set(data.turn, values) + else steps.set(stepDataKey(data.turn, requireStep(data)), values) + } + let changed = false + for (const turn of new Set([...this.turnDataStores.keys(), ...turns.keys()])) { + changed = this.mutableTurnData(turn).replace(turns.get(turn) ?? new Map()) || changed + } + for (const step of new Set([...this.stepDataStores.keys(), ...steps.keys()])) { + changed = this.mutableStepData(step).replace(steps.get(step) ?? new Map()) || changed + } + return changed + } + + /** Apply changed Context publications without rebuilding Turn/Step membership. */ + applyData(changes: readonly ConversationLocationDataChange[]): boolean { + let changed = false + for (const change of changes) { + const previous = change.previous + if (previous === null) continue + changed = this.storeFor(previous).remove(change.owner, previous.key) || changed + } + for (const change of changes) { + const next = change.next + if (next === null) continue + changed = this.storeFor(next).set(change.owner, next.key, next.value) || changed + } + return changed + } + + /** + * Resolve the latest Location for one event. + * @param event - event already ingested into this index. + * @returns current Location, falling back to session when it has no Turn/Step affinity. + */ + locationOf(event: SessionEvent): ConversationLocation { + return this.locations.get(event.seq) ?? SESSION_LOCATION + } + + /** + * Rebuild timeline facts after replace/prepend or a boundary append. + * @param entries - complete current window in ascending seq order. + * @returns seqs whose resolved Location changed. + */ + rebuild(entries: readonly ConversationEventInput[]): ReadonlySet { + const previousLocations = this.locations + const turns = new Map() + const coordinates = new Map() + let currentTurn: number | undefined + let currentStep: number | undefined + + const turnDraft = (turn: number, seq: number): TurnDraft => { + let draft = turns.get(turn) + if (draft === undefined) { + draft = { turn, firstSeq: seq, steps: new Map() } + turns.set(turn, draft) + } else { + draft.firstSeq = Math.min(draft.firstSeq, seq) + } + return draft + } + const stepDraft = (turn: number, step: number, seq: number): StepDraft => { + const owner = turnDraft(turn, seq) + let draft = owner.steps.get(step) + if (draft === undefined) { + draft = { turn, step, firstSeq: seq } + owner.steps.set(step, draft) + } else { + draft.firstSeq = Math.min(draft.firstSeq, seq) + } + return draft + } + + for (const { event } of entries) { + const explicit = payloadCoordinates(event) + if (event.type === 'turn/start') { + currentTurn = event.data.turn + currentStep = undefined + } + if (event.type === 'step/start') { + currentTurn = event.data.turn + currentStep = event.data.step + } + if (explicit.session !== true && explicit.turn !== undefined) { + if (currentTurn !== explicit.turn) currentStep = undefined + currentTurn = explicit.turn + if (explicit.step !== undefined) currentStep = explicit.step + } + const turn = explicit.session === true ? undefined : explicit.turn ?? currentTurn + const step = explicit.session === true || event.type === 'turn/start' || event.type === 'turn/end' + ? undefined + : explicit.step ?? (turn === currentTurn ? currentStep : undefined) + coordinates.set(event.seq, { + ...turn === undefined ? {} : { turn }, + ...turn === undefined || step === undefined ? {} : { step }, + }) + if (turn !== undefined) turnDraft(turn, event.seq) + if (turn !== undefined && step !== undefined) stepDraft(turn, step, event.seq) + + if (event.type === 'turn/start') { + turnDraft(event.data.turn, event.seq).start = event + } else if (event.type === 'turn/end') { + turnDraft(event.data.turn, event.seq).end = event + } else if (event.type === 'step/start') { + stepDraft(event.data.turn, event.data.step, event.seq).start = event + } else if (event.type === 'step/end') { + stepDraft(event.data.turn, event.data.step, event.seq).end = event + } + + if (event.type === 'step/end' && currentTurn === event.data.turn && currentStep === event.data.step) { + currentStep = undefined + } + if (event.type === 'turn/end' && currentTurn === event.data.turn) { + currentTurn = undefined + currentStep = undefined + } + } + + const previousTurns = this.timeline.turns + const nextTurns = new Map() + const orderedDrafts = [...turns.values()].sort((left, right) => left.firstSeq - right.firstSeq) + for (const draft of orderedDrafts) { + const previousTurn = previousTurns.get(draft.turn) + const previousSteps = new Map(previousTurn?.steps.map(step => [step.step, step]) ?? []) + const steps = [...draft.steps.values()] + .sort((left, right) => left.firstSeq - right.firstSeq) + .map((candidate): StepLocation => { + const value: StepLocation = { + turn: candidate.turn, + step: candidate.step, + start: candidate.start, + end: candidate.end, + status: candidate.end !== undefined + ? 'closed' + : candidate.start === undefined ? 'unknown' : 'open', + data: this.stepData(candidate.turn, candidate.step), + } + const previous = previousSteps.get(candidate.step) + return sameStep(previous, value) ? previous as StepLocation : value + }) + const value: TurnLocation = { + turn: draft.turn, + start: draft.start, + end: draft.end, + status: draft.end !== undefined ? 'closed' : draft.start === undefined ? 'unknown' : 'open', + steps, + data: this.turnData(draft.turn), + } + nextTurns.set(draft.turn, sameTurn(previousTurn, value) ? previousTurn as TurnLocation : value) + } + + const nextOrder = orderedDrafts.map(draft => draft.turn) + const turnOrder = this.timeline.turnOrder.length === nextOrder.length + && this.timeline.turnOrder.every((turn, index) => turn === nextOrder[index]) + ? this.timeline.turnOrder + : nextOrder + let sameMap = previousTurns.size === nextTurns.size + if (sameMap) { + for (const [turn, value] of nextTurns) { + if (previousTurns.get(turn) !== value) { + sameMap = false + break + } + } + } + this.timeline = sameMap && turnOrder === this.timeline.turnOrder + ? this.timeline + : { turnOrder, turns: nextTurns } + this.coordinates = coordinates + this.locations = new Map() + this.seqsByTurn = new Map() + for (const { event } of entries) { + const coordinates = this.coordinates.get(event.seq) + if (coordinates?.turn !== undefined) this.indexTurnSeq(coordinates.turn, event.seq) + this.locations.set(event.seq, this.resolve(event.seq)) + } + this.currentTurn = currentTurn + this.currentStep = currentStep + + const changed = new Set() + for (const { event } of entries) { + if (!sameLocation(previousLocations.get(event.seq), this.locations.get(event.seq))) { + changed.add(event.seq) + } + } + return changed + } + + /** + * Append one Turn/Step boundary while revisiting only the owning Turn. + * @param event - contiguous tail boundary event. + * @returns seqs whose immutable Location reference changed. + */ + appendBoundary(event: SessionEvent): ReadonlySet { + if (event.type !== 'turn/start' && event.type !== 'turn/end' + && event.type !== 'step/start' && event.type !== 'step/end') { + throw new Error(`conversation Location boundary expected, received ${event.type}`) + } + + const explicit = payloadCoordinates(event) + if (event.type === 'turn/start') { + this.currentTurn = event.data.turn + this.currentStep = undefined + } else if (event.type === 'step/start') { + this.currentTurn = event.data.turn + this.currentStep = event.data.step + } + if (explicit.turn !== undefined) { + if (this.currentTurn !== explicit.turn) this.currentStep = undefined + this.currentTurn = explicit.turn + if (explicit.step !== undefined) this.currentStep = explicit.step + } + const turnNumber = explicit.turn ?? this.currentTurn + if (turnNumber === undefined) throw new Error(`conversation boundary ${event.type} has no turn`) + const stepNumber = event.type === 'turn/start' || event.type === 'turn/end' + ? undefined + : explicit.step ?? (turnNumber === this.currentTurn ? this.currentStep : undefined) + this.coordinates.set(event.seq, { + turn: turnNumber, + ...stepNumber === undefined ? {} : { step: stepNumber }, + }) + this.indexTurnSeq(turnNumber, event.seq) + + const previousTurn = this.timeline.turns.get(turnNumber) + let steps = previousTurn?.steps ?? [] + if (event.type === 'step/start' || event.type === 'step/end') { + const number = event.data.step + const previousStep = steps.find(candidate => candidate.step === number) + const candidate: StepLocation = { + turn: turnNumber, + step: number, + start: event.type === 'step/start' ? event : previousStep?.start, + end: event.type === 'step/end' ? event : previousStep?.end, + status: event.type === 'step/end' || previousStep?.end !== undefined ? 'closed' : 'open', + data: this.stepData(turnNumber, number), + } + const nextStep = sameStep(previousStep, candidate) ? previousStep as StepLocation : candidate + const index = steps.findIndex(step => step.step === number) + steps = index < 0 + ? [...steps, nextStep] + : steps.map((step, at) => at === index ? nextStep : step) + } + const candidate: TurnLocation = { + turn: turnNumber, + start: event.type === 'turn/start' ? event : previousTurn?.start, + end: event.type === 'turn/end' ? event : previousTurn?.end, + status: event.type === 'turn/end' || previousTurn?.end !== undefined + ? 'closed' + : event.type === 'turn/start' || previousTurn?.start !== undefined ? 'open' : 'unknown', + steps, + data: this.turnData(turnNumber), + } + const turn = sameTurn(previousTurn, candidate) ? previousTurn as TurnLocation : candidate + const turns = new Map(this.timeline.turns) + turns.set(turnNumber, turn) + const turnOrder = previousTurn === undefined + ? [...this.timeline.turnOrder, turnNumber] + : this.timeline.turnOrder + this.timeline = { turnOrder, turns } + + const changed = new Set() + for (const seq of this.seqsByTurn.get(turnNumber) ?? []) { + const previous = this.locations.get(seq) + const next = this.resolve(seq) + this.locations.set(seq, next) + if (!sameLocation(previous, next)) changed.add(seq) + } + + if (event.type === 'step/end' && this.currentTurn === event.data.turn && this.currentStep === event.data.step) { + this.currentStep = undefined + } + if (event.type === 'turn/end' && this.currentTurn === event.data.turn) { + this.currentTurn = undefined + this.currentStep = undefined + } + return changed + } + + /** + * Index one non-boundary tail event without rescanning the window. + * @param event - contiguous appended event. + */ + appendNonBoundary(event: SessionEvent): void { + const explicit = payloadCoordinates(event) + if (explicit.session === true) { + this.coordinates.set(event.seq, {}) + this.locations.set(event.seq, SESSION_LOCATION) + return + } + if (explicit.turn !== undefined) { + if (this.currentTurn !== explicit.turn) this.currentStep = undefined + this.currentTurn = explicit.turn + if (explicit.step !== undefined) this.currentStep = explicit.step + } + const turn = explicit.turn ?? this.currentTurn + const step = explicit.step ?? (turn === this.currentTurn ? this.currentStep : undefined) + this.coordinates.set(event.seq, { + ...turn === undefined ? {} : { turn }, + ...turn === undefined || step === undefined ? {} : { step }, + }) + if (turn !== undefined) this.indexTurnSeq(turn, event.seq) + this.locations.set(event.seq, this.resolve(event.seq)) + } + + private indexTurnSeq(turn: number, seq: number): void { + const current = this.seqsByTurn.get(turn) ?? new Set() + current.add(seq) + this.seqsByTurn.set(turn, current) + } + + private turnData(turn: number): ConversationLocationDataStore { + return this.mutableTurnData(turn) as ConversationLocationDataStore + } + + private stepData(turn: number, step: number): ConversationLocationDataStore { + return this.mutableStepData(stepDataKey(turn, step)) as ConversationLocationDataStore + } + + private mutableTurnData(turn: number): MutableLocationDataStore { + const current = this.turnDataStores.get(turn) ?? new MutableLocationDataStore() + this.turnDataStores.set(turn, current) + return current + } + + private mutableStepData(key: string): MutableLocationDataStore { + const current = this.stepDataStores.get(key) ?? new MutableLocationDataStore() + this.stepDataStores.set(key, current) + return current + } + + private storeFor(data: ConversationLocationData): MutableLocationDataStore { + return data.kind === 'turn' + ? this.mutableTurnData(data.turn) + : this.mutableStepData(stepDataKey(data.turn, requireStep(data))) + } + + private resolve(seq: number): ConversationLocation { + const coordinates = this.coordinates.get(seq) + if (coordinates?.turn === undefined) return SESSION_LOCATION + const turn = this.timeline.turns.get(coordinates.turn) + if (turn === undefined) return UNRESOLVED_LOCATION + if (coordinates.step === undefined) return { kind: 'turn', turn } + const step = turn.steps.find(candidate => candidate.step === coordinates.step) + return step === undefined ? { kind: 'turn', turn } : { kind: 'step', turn, step } + } +} + +function stepDataKey(turn: number, step: number): string { + return `${turn}:${step}` +} + +function requireStep(data: ConversationLocationData): number { + if (data.kind === 'step' && data.step !== undefined) return data.step + throw new Error(`conversation Step data "${data.key}" requires a step`) +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 2681bb33f3..99daa7c8a5 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -13,6 +13,9 @@ import type { } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' +import type { + ChatConversationViewNode, ConversationTimelineSnapshot, +} from '../contract/conversation.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -206,7 +209,7 @@ export interface CompactionSummaryNode { * Fallback for surface events this UI version does not know: the documented * default arm of `SessionEventMap`, which is merge-extensible, so the * projection's switch cannot end in `assertNever`. No event produces this node - * today — `isAppendSurfaceEvent` admits only the four types in core's + * today — `isAppendSurfaceEvent` admits only the three types in core's * `SurfaceEventType`, and each has its own arm — and it exists so widening that * set core-side degrades to a raw row instead of dropping the event silently. */ @@ -222,8 +225,8 @@ export interface UnknownSurfaceNode { /** * One slash-command lifecycle folded from the log-only `command/run` / * `command/done` pair (paired by commandId, mirroring tool call↔result). - * Log-only events are not surface events, so the TranscriptAdapter indexes - * them separately and merges the nodes into the flow by seq. A window cut + * Log-only events are not surface events, so the command Definition indexes + * them separately and the Chat builder orders the resulting node by seq. A window cut * between the pair soft-falls like tool pairs: a done with no in-window run * still builds a node (name/args null), and a run with no done renders as * still executing. @@ -311,21 +314,19 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error' * Input-area shape of an OPEN session, derived at snapshot assembly (the one * place that knows the predicate — consumers switch, never re-derive): * - * - `blank`: no activity ever (no nodes, no partial, not running, no pending - * waits, no prompt attempt) — the UI renders the blank-session guidance - * hero. - * - `engaging`: the first prompt was initiated but no content landed yet — - * the UI holds the composer through the accept → running → first-event - * frames. Entered synchronously before prompt()'s first await. - * - `active`: content exists (nodes, partial, running turn, or pending - * waits) — the ordinary conversation view. + * - `blank`: the authoritative blank bit is still set and no prompt was + * attempted — the UI renders the blank-session guidance hero. + * - `engaging`: a first prompt was attempted, but no accepted turn or other + * authoritative activity signal has arrived — the UI keeps the composer + * visible through admission and error frames. + * - `active`: the session is non-blank beyond its pending first prompt, is + * running, or owns a pending interaction — the ordinary conversation view. * - * Monotone within a session object: blank → engaging → active, no returns. * A failed first prompt stays `engaging` (composer + error strip — retry - * semantics; bouncing back to the hero would discard the error context). + * semantics; returning to the hero would discard the error context). * Sessions whose window is not open (`loading`/`error`) are outside phase * jurisdiction: consumers branch on {@link ConversationSnapshot.openState} - * first (phase still reports `active`-ish facts but must not be rendered). + * first. */ export type ComposerPhase = 'blank' | 'engaging' | 'active' @@ -335,10 +336,70 @@ export interface PromptError { error: RpcError } +/** Stable per-key reader for final Chat view Nodes. */ +export interface ChatNodeStore { + /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ + get(key: string): ChatConversationViewNode | undefined + /** @returns all currently materialized Nodes without imposing render order. */ + values(): readonly ChatConversationViewNode[] +} + +/** Stable per-Location membership index for turn-local and step-local consumers. */ +export interface ChatLocationNodeIndex { + /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ + getTurn(turn: number): readonly string[] + /** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */ + getStep(turn: number, step: number): readonly string[] +} + +/** Temporary projection consumed by Trajectory and unmigrated readers. */ +export interface LegacyConversationSlice { + readonly nodes: readonly ConversationNode[] + readonly turnTimings: ReadonlyMap + readonly turnEnds: ReadonlyMap + readonly partial: PartialAssistant | null + readonly runningCalls: readonly RunningToolCall[] +} + +/** Incremental Chat target snapshot: stable keyed stores plus structural order. */ +export interface ChatSnapshot { + readonly order: readonly string[] + readonly nodes: ChatNodeStore + readonly locations: ChatLocationNodeIndex + readonly timeline: ConversationTimelineSnapshot + readonly legacy: LegacyConversationSlice +} + +const EMPTY_LIST: readonly never[] = [] +const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } + +/** Empty Chat target used before a view builder is registered. */ +export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { + order: EMPTY_LIST, + nodes: { + get: () => undefined, + values: () => EMPTY_LIST, + }, + locations: { + getTurn: () => EMPTY_LIST, + getStep: () => EMPTY_LIST, + }, + timeline: EMPTY_TIMELINE, + legacy: { + nodes: EMPTY_LIST, + turnTimings: new Map(), + turnEnds: new Map(), + partial: null, + runningCalls: EMPTY_LIST, + }, +} + /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId - /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ + /** Final Chat target assembled from independently registered business Definitions. */ + chat: ChatSnapshot + /** Legacy Trajectory slice derived from the registered Chat Definitions. */ nodes: readonly ConversationNode[] /** Exact in-window `turn/start` time and optional matching `turn/end` time. */ turnTimings: ReadonlyMap diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 64199c4812..b4eec9d6df 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -10,6 +10,7 @@ import type { // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' +import type { ConversationRuntime } from './conversation-assembler.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import type { PendingInteractionStatus } from './pending.ts' @@ -158,6 +159,7 @@ export class SessionManager { private readonly api: IApiClient, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, + private readonly conversation?: ConversationRuntime, ) { this.selected = restoredSelection if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress) @@ -282,7 +284,12 @@ export class SessionManager { const address = this.addresses.get(sessionId) const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries .find(entry => entry.kind === 'child' && entry.id === sessionId) - if (child?.kind === 'child') session.handleRunning(child.activity === 'running') + if (child?.kind === 'child') { + // A catalogued child exists only after its delegated session has + // durable history, even though child rows do not carry `blank`. + session.handleBlank(false) + session.handleRunning(child.activity === 'running') + } } } return session @@ -301,9 +308,15 @@ export class SessionManager { this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, projections: this.projectionStore(sessionId), + ...this.conversation === undefined ? {} : { conversation: this.conversation }, }) } + /** Rebuild every resident Session after one coalesced registry transaction. */ + rebuildConversationRegistry(): void { + for (const session of this.sessions.values()) session.rebuildConversationRegistry() + } + /** Resident per-session projection store (create-on-demand; outlives instantiation). */ private projectionStore(sessionId: SessionId): ProjectionValueStore { let store = this.projectionStores.get(sessionId) diff --git a/packages/client/runtime/src/client/sessions/partial.ts b/packages/client/runtime/src/client/sessions/partial.ts index 31a04124cd..599ea61c3d 100644 --- a/packages/client/runtime/src/client/sessions/partial.ts +++ b/packages/client/runtime/src/client/sessions/partial.ts @@ -48,7 +48,7 @@ export class PartialAccumulator { push(chunk: StreamChunk): boolean { switch (chunk.type) { case 'block-start': { - this.blocks[chunk.index] = emptyBlock(chunk.blockType) + this.blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) this.changed = true return true } @@ -102,7 +102,12 @@ export class PartialAccumulator { } } -function emptyBlock(blockType: string): AssistantBlock { +/** + * Create the empty client projection for one streamed Assistant block kind. + * @param blockType - wire block kind. + * @returns empty projected block ready to receive deltas. + */ +export function emptyAssistantBlock(blockType: string): AssistantBlock { switch (blockType) { case 'text': return { kind: 'text', text: '' } case 'reasoning': return { kind: 'reasoning', text: '' } diff --git a/packages/client/runtime/src/client/sessions/queue-mirror.ts b/packages/client/runtime/src/client/sessions/queue-mirror.ts new file mode 100644 index 0000000000..be7351cf5f --- /dev/null +++ b/packages/client/runtime/src/client/sessions/queue-mirror.ts @@ -0,0 +1,74 @@ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { MuxFrame } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { QueuedMessage } from './conversation.ts' + +const QUEUE_PREVIEW_CHARS = 200 + +function previewOf(content: readonly ContentBlock[]): string { + const flat = content + .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) + .join(' ').replace(/\s+/g, ' ').trim() + const chars = Array.from(flat) + return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat +} + +function textOf(content: readonly ContentBlock[]): string | null { + if (!content.every(block => block.type === 'text')) return null + return content.map(block => block.text).join('') +} + +type QueueItems = Extract['items'] + +/** Authoritative transient queue projection and durable steering handoff. */ +export class SessionQueueMirror { + private current: readonly QueuedMessage[] = [] + + /** + * Return the current immutable queue projection. + * @returns current queue rows. + */ + snapshot(): readonly QueuedMessage[] { + return this.current + } + + /** + * Drop the stale generation before its replacement queue baseline arrives. + * @returns whether any projected queue row was removed. + */ + reset(): boolean { + if (this.current.length === 0) return false + this.current = [] + return true + } + + /** + * Replace from one authoritative stream queue frame. + * @param items - complete host queue snapshot. + */ + replace(items: QueueItems): void { + this.current = items.map(item => ({ + id: item.id, + messageId: item.message.id, + placement: item.placement, + content: item.message.content, + preview: previewOf(item.message.content), + text: textOf(item.message.content), + })) + } + + /** + * Retire a transient steering row once its durable message enters the log. + * @param event - newly contiguous durable Session event. + * @returns whether the projection changed. + */ + acceptDurable(event: SessionEvent): boolean { + if (event.type !== 'user/message') return false + const messageId = event.data.id + const index = this.current.findIndex(item => + item.placement === 'steering' && item.messageId === messageId) + if (index < 0) return false + this.current = this.current.filter((_item, candidate) => candidate !== index) + return true + } +} diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 64eeaf91d7..dcb5f71610 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -31,6 +31,7 @@ import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' +import type { ConversationRuntime } from './conversation-assembler.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { PendingInteractionStatus } from './pending.ts' @@ -259,16 +260,30 @@ export class SessionsService implements ISessions { /** * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. + * @param conversationRuntime - same-pass registry instances, when runtime apply owns them. */ constructor( private readonly rootCtx: Context, api: IApiClient, + conversationRuntime?: ConversationRuntime, ) { this.selection = createSnapshotStore( {}, { persist: { name: 'dsh.sessions.current' } }) const restored = this.selection.getSnapshot() - this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress) + const conversationEvents = rootCtx.get('conversationEvents') + const conversationViews = rootCtx.get('conversationViews') + const conversation = conversationRuntime ?? ( + conversationEvents === undefined || conversationViews === undefined + ? undefined + : { events: conversationEvents, views: conversationViews } + ) + this.manager = new SessionManager( + api, + restored.sessionId, + restored.subagentAddress, + conversation, + ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', subagentsByParent: {}, currentAddress: undefined, @@ -296,6 +311,25 @@ export class SessionsService implements ISessions { resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current), }) this.currentProvideInfo = this.provideChannel.currentProvideInfo + let registryRebuildQueued = false + const scheduleRegistryRebuild = (): void => { + if (registryRebuildQueued) return + registryRebuildQueued = true + queueMicrotask(() => { + registryRebuildQueued = false + this.manager.rebuildConversationRegistry() + }) + } + if (conversation !== undefined) { + rootCtx.effect(() => { + const disposeEvents = conversation.events.subscribe(scheduleRegistryRebuild) + const disposeViews = conversation.views.subscribe(scheduleRegistryRebuild) + return () => { + disposeEvents() + disposeViews() + } + }, 'sessions: conversation registry rebuild') + } rootCtx.reflect.provide('sessions', this, undefined) } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0813c02711..a533f9dd6f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -2,7 +2,6 @@ import type { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, @@ -12,27 +11,23 @@ import type { // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionFace } from '../contract/session.ts' +import { ConversationNodeAssembler } from './conversation-assembler.ts' +import type { ConversationRuntime } from './conversation-assembler.ts' +import type { ConversationEventInput, ConversationPublication } from '../contract/conversation.ts' import type { - ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode, - OpenState, PromptError, QueuedMessage, RunningToolCall, + ChatSnapshot, ComposerPhase, ConversationSnapshot, OpenState, PromptError, } from './conversation.ts' +import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' -import { TranscriptAdapter } from './transcript-adapter.ts' -import { displayFailureMessage } from './failure-display.ts' import { Notifier } from './notifier.ts' -import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' -import { ToolCallTree } from './tool-call-tree.ts' +import { SessionQueueMirror } from './queue-mirror.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 -// Browser bundles cannot value-import the host timeout library. This protocol -// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests. -const MAX_RETRY_DELAY_MS = 2_147_483_647 - /** Manager-owned observers of a Session object's local state edges. */ export interface SessionOptions { /** Catalog-discovered address selecting non-activating subagent transport. */ @@ -54,24 +49,8 @@ export interface SessionOptions { * private store (bare object-layer construction). */ projections?: ProjectionValueStore -} - -/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ -const QUEUE_PREVIEW_CHARS = 200 - -/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */ -function queuePreviewOf(content: readonly ContentBlock[]): string { - const flat = content - .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) - .join(' ').replace(/\s+/g, ' ').trim() - const chars = Array.from(flat) - return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat -} - -/** Recover complete composer text only when editing cannot discard non-text blocks. */ -function queueTextOf(content: readonly ContentBlock[]): string | null { - if (!content.every(block => block.type === 'text')) return null - return content.map(block => block.text).join('') + /** Runtime registries used by this Session-owned Conversation assembler. */ + conversation?: ConversationRuntime } /** @@ -96,42 +75,13 @@ export class Session implements SessionFace { * passes drop all writes once the generation moves on. */ private openGeneration = 0 private loadingOlder = false - private readonly transcript = new TranscriptAdapter() - private partial: PartialAccumulator | null = null - private openCalls = new Map() - /** Last entered step per turn, folded from step/start for terminal error placement. */ - private lastStepByTurn = new Map() - /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq. - * Derived from window events and rebuilt with partial/openCalls; the transcript is - * seq-monotonic, so a plain seq merge preserves event order. */ - private derivedNodes: ConversationNode[] = [] private pending = new Map() - // Revision counters preserve array identity when derived content is unchanged, so - // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every - // tool card and pending card). Mutation sites bump the matching revision. partial needs no - // counter — PartialAccumulator.toPartial already returns a cached reference when unchanged. - private callsRev = 0 - private callsCache: { rev: number; value: RunningToolCall[] } | null = null private pendingRev = 0 private pendingCache: { rev: number; value: PendingInteraction[] } | null = null - private derivedRev = 0 - private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null - /** Exact turn timing retained from the raw window so presentation never - * infers elapsed time from transcript content. */ - private turnTimings = new Map() - private turnTimingsRev = 0 - private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null - /** Completed turn boundaries retained from the raw window so presentation - * actions never infer a safe fork point from transcript content alone. */ - private turnEnds = new Map() - private turnEndsRev = 0 - private turnEndsCache: { rev: number; value: ReadonlyMap } | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ - private queued: QueuedMessage[] = [] - private queueRev = 0 - private queueCache: { rev: number; value: QueuedMessage[] } | null = null - /** Window-derived child-call lifecycle and immutable tree projection. */ - private readonly toolCallTree = new ToolCallTree() + private readonly queueMirror = new SessionQueueMirror() + /** Session-owned business Context engine over the contiguous raw window. */ + private readonly conversation: ConversationNodeAssembler private running = false private address: SubagentAddress | undefined private parentAvailable = false @@ -141,8 +91,10 @@ export class Session implements SessionFace { * engaging edge of the phase machine (see ComposerPhase). */ private promptAttempted = false - /** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */ - private blankBit = false + /** A first accepted prompt stays in the engaging phase until its turn is observable. */ + private firstPromptPendingTurn = false + /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */ + private blankBit = true private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null @@ -167,9 +119,7 @@ export class Session implements SessionFace { readonly projections: ProjectionValueStore private snapshotCache: ConversationSnapshot - private readonly notifier = new Notifier(() => { - this.snapshotCache = this.buildSnapshot() - }) + private readonly notifier: Notifier /** * Agent-scoped cordis context, bound once by SessionsService when it * mints the scope (the client mirror of the host Agent's loopCtx). The @@ -192,6 +142,16 @@ export class Session implements SessionFace { this.projections = options.projections ?? new ProjectionValueStore() this.address = options.address this.parentAvailable = options.parentAvailable ?? false + this.conversation = options.conversation === undefined + ? new ConversationNodeAssembler( + { entries: () => [], fallbackEntry: () => undefined }, + { entries: () => [] }, + ) + : new ConversationNodeAssembler(options.conversation.events, options.conversation.views) + this.notifier = new Notifier(() => { + this.conversation.flush() + this.snapshotCache = this.buildSnapshot() + }) this.snapshotCache = this.buildSnapshot() } @@ -228,6 +188,7 @@ export class Session implements SessionFace { // visible on the session area's very first frame when a caller sends // ahead of navigation (first-send flow). this.promptAttempted = true + if (this.blankBit) this.firstPromptPendingTurn = true this.notifier.markDirty() let result: RpcResult<{ accepted: true }> try { @@ -375,6 +336,7 @@ export class Session implements SessionFace { const older = result.value.events if (older.length === 0) { this.hasMore = result.value.hasMore + this.conversation.prepend([], this.hasMore) return } const tail = older[older.length - 1] @@ -389,8 +351,7 @@ export class Session implements SessionFace { /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ this.baseSeq = older[0]?.event.seq ?? this.baseSeq this.hasMore = result.value.hasMore - this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head) - this.rebuildDerivedFromWindow() + this.conversation.prepend(older.map(conversationInput), this.hasMore) } catch (error) { console.error('[web-runtime] loadOlder failed:', error) } finally { @@ -461,15 +422,7 @@ export class Session implements SessionFace { return } case 'session/queue': { - this.queued = frame.items.map(item => ({ - id: item.id, - messageId: item.message.id, - placement: item.placement, - content: item.message.content, - preview: queuePreviewOf(item.message.content), - text: queueTextOf(item.message.content), - })) - this.queueRev++ + this.queueMirror.replace(frame.items) this.notifier.markDirty() return } @@ -479,11 +432,7 @@ export class Session implements SessionFace { // snapshot AFTER the subscribed frame on the same stream, so the // stale mirror clears here — race-free against onConnected/resync // timing (clearing there could wipe a baseline that already landed). - if (this.queued.length > 0) { - this.queued = [] - this.queueRev++ - this.notifier.markDirty() - } + if (this.queueMirror.reset()) this.notifier.markDirty() return } case 'approval/requested': { @@ -527,6 +476,7 @@ export class Session implements SessionFace { this.blankBit = false this.notifier.markDirty() } + if (running) this.firstPromptPendingTurn = false if (this.running === running) return this.running = running this.notifier.markDirty() @@ -590,6 +540,11 @@ export class Session implements SessionFace { /** No-op because session instances remain resident. */ dispose(): void {} + /** Rebuild the current window after a low-frequency Definition or view registration change. */ + rebuildConversationRegistry(): void { + this.scheduleConversation(this.conversation.rebuildRegistry()) + } + // ---- 私有 ---- /** Requested-frame arrival: the wait enters the pending map under its own key. */ @@ -651,8 +606,8 @@ export class Session implements SessionFace { this.views = entries.map(e => e.view) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore - this.transcript.reset(this.events, this.views) - this.rebuildDerivedFromWindow() + if (this.events.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false + this.conversation.replaceWindow(entries.map(conversationInput), hasMore) if (projections !== undefined) this.projections.seed(projections) const buffered = this.liveBuffer this.liveBuffer = [] @@ -661,32 +616,22 @@ export class Session implements SessionFace { } /** Seq-guarded append shared by stitching and the open-state live path. */ - private appendLive(event: SessionEvent, view?: ToolEventView): void { + private appendLive(event: SessionEvent, view?: ToolEventView): ConversationPublication { const tailSeq = this.windowTailSeq() - if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop + if (tailSeq !== null && event.seq <= tailSeq) return 'none' // replay overlap, drop this.events.push(event) this.views.push(view) - this.transcript.append(event, view) - this.handoffPendingSteering(event) - this.applyEventSideEffects(event, view) - } - - /** Retire the first matching live steering occurrence when its durable message takes over. */ - private handoffPendingSteering(event: SessionEvent): void { - if (event.type !== 'user/message') return - const message = event.data - const index = this.queued.findIndex(item => - item.placement === 'steering' && item.messageId === message.id) - if (index === -1) return - this.queued = this.queued.filter((_item, candidate) => candidate !== index) - this.queueRev++ + if (event.type === 'turn/start') this.firstPromptPendingTurn = false + const queueChanged = this.queueMirror.acceptDurable(event) + const publication = this.conversation.append({ event, view }) + return queueChanged ? 'immediate' : publication } /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop; * a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an * expected reconnect-window artifact, repaired by refetch). The window stays one contiguous - * raw range, which is what lets the transcript render every event between its ends and lets a - * compaction checkpoint find its cited summary event. */ + * raw range, which lets Conversation Definitions correlate every recorded event between its + * ends and lets a compaction checkpoint resolve its cited summary event. */ private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void { if (this.openState === 'loading' || this.stitching) { this.liveBuffer.push({ event, view }) @@ -699,12 +644,13 @@ export class Session implements SessionFace { void this.repairGap() return } - this.appendLive(event, view) - if (event.type === 'assistant/chunk') { - if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty() - return - } - this.notifier.markDirty() + this.scheduleConversation(this.appendLive(event, view)) + } + + /** Route assembler cadence into the Session's existing microtask/RAF notifier. */ + private scheduleConversation(publication: ConversationPublication): void { + if (publication === 'immediate') this.notifier.markDirty() + else if (publication === 'animation-frame') this.notifier.markFrameDirty() } /** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared @@ -728,238 +674,35 @@ export class Session implements SessionFace { } } - /** Per-event side effects (right column of the §A.9 dispatch table): - * chunk/retry projection and openCalls add-remove. */ - private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { - const eventType = event.type as string - if (eventType === 'llm/retry') { - const data = parseRetryEventData(event.data) - if (data === null) { - console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`) - return - } - if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) { - this.partial = null - } - this.derivedNodes.push({ - kind: 'model-retry', - seq: event.seq, - time: event.time, - retryState: 'scheduled', - ...data, - }) - this.derivedRev++ - return - } - // These lifecycle events are declared by a host-only plugin whose Context - // types cannot enter the client program. ToolCallTree owns their structural - // wire narrowing, pairing, and nested snapshot projection. - if (this.toolCallTree.apply(event)) return - switch (event.type) { - case 'turn/start': - this.lastStepByTurn.set(event.data.turn, 0) - this.turnTimings.set(event.data.turn, { startTime: event.time }) - this.turnTimingsRev++ - return - case 'step/start': - this.lastStepByTurn.set(event.data.turn, event.data.step) - return - case 'assistant/chunk': { - const { turn, step, chunk } = event.data - this.settleScheduledRetry('started', turn) - if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) { - this.partial = new PartialAccumulator(turn, step) - } - this.partial.push(chunk) - return - } - case 'assistant/message': { - if (this.partial !== null && this.partial.turn === event.data.turn && this.partial.step === event.data.step) { - this.partial = null // finalize swaps in place (same notification batch, no flicker) - } - return - } - case 'tool/call': { - this.openCalls.set(String(event.data.callId), { - callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, - turn: event.data.turn, step: event.data.step, time: event.time, - callView: view?.for === 'call' ? view.view : null, - subCalls: [], - }) - this.callsRev++ - return - } - case 'tool/result': { - if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++ - return - } - case 'turn/end': { - const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0 - const timing = this.turnTimings.get(event.data.turn) - if (timing !== undefined) { - this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time }) - this.turnTimingsRev++ - } - this.turnEnds.set(event.data.turn, event.seq) - this.turnEndsRev++ - if (event.data.reason.kind === 'aborted') { - this.settleScheduledRetry('cancelled', event.data.turn) - } - if ( - event.data.reason.kind === 'error' - && !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn) - ) { - const failure = event.data.reason.error - this.derivedNodes.push({ - kind: 'turn-error', - seq: event.seq, - time: event.time, - turn: event.data.turn, - step: lastStep, - message: displayFailureMessage(failure), - code: failure.code, - }) - this.derivedRev++ - } - if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn) - // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it - // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. - // Shared by live and window-replay paths, so a refresh reconstructs the same frozen node - // from the logged chunks. Content-free partials are dropped outright. - if (this.partial !== null && this.partial.turn === event.data.turn) { - const { blocks } = this.partial.toPartial() - const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true)) - if (visible) { - // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. - this.derivedNodes.push({ - kind: 'assistant', seq: event.seq - 0.9, time: event.time, - turn: this.partial.turn, step: this.partial.step, - blocks, interrupted: true, - }) - this.derivedRev++ - } - this.partial = null - } - let callOffset = 0 - for (const [callId, call] of this.openCalls) { - if (call.turn !== event.data.turn) continue - this.openCalls.delete(callId) - this.callsRev++ - // The spinner card becomes an interrupted terminal card (never vanishes mid-flow). - this.derivedNodes.push({ - kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, - callId, - call: { name: call.name, argsRaw: call.argsRaw }, - callTime: call.time, - content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, - callView: call.callView, resultView: null, subCalls: [], - }) - this.derivedRev++ - } - this.lastStepByTurn.delete(event.data.turn) - return - } - default: - return - } - } - - /** - * Settle the newest scheduled retry, optionally restricted to its failed turn. - * @param retryState - next client projection state to publish. - * @param turn - failed turn required for cancellation; omitted for the next retry turn start. - */ - private settleScheduledRetry( - retryState: Exclude, - turn?: number, - ): void { - const index = this.derivedNodes.findLastIndex(node => - node.kind === 'model-retry' - && node.retryState === 'scheduled' - && (turn === undefined || node.turn === turn)) - if (index < 0) return - const node = this.derivedNodes[index] - /* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */ - if (node?.kind !== 'model-retry') return - this.derivedNodes[index] = { ...node, retryState } - this.derivedRev++ - } - - /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps - * paging/stitching consistent, and makes live handling and history replay converge on the same - * retry notices and interrupted nodes. */ - private rebuildDerivedFromWindow(): void { - this.partial = null - this.openCalls.clear() - this.lastStepByTurn.clear() - this.callsRev++ - this.derivedNodes = [] - this.derivedRev++ - this.turnTimings = new Map() - this.turnTimingsRev++ - this.turnEnds = new Map() - this.turnEndsRev++ - this.toolCallTree.reset() - for (let i = 0; i < this.events.length; i++) { - const event = this.events[i] - /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ - if (event !== undefined) this.applyEventSideEffects(event, this.views[i]) - } - } - private windowTailSeq(): number | null { const tail = this.events[this.events.length - 1] return tail === undefined ? null : tail.seq } private buildSnapshot(): ConversationSnapshot { - const projected = this.transcript.nodes() - // Derived interruption nodes ride fractional seqs while retry notices keep their event seq. - // The transcript is seq-monotonic, so sorting the union preserves flow order. Cache the - // merge on (projected reference, derivedRev) to retain identity across unrelated swaps. - let nodes: readonly ConversationNode[] - if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) { - nodes = this.nodesCache.value - } else { - nodes = this.derivedNodes.length === 0 - ? projected - : [...projected, ...this.derivedNodes].sort((a, b) => a.seq - b.seq) - this.nodesCache = { projected, derivedRev: this.derivedRev, value: nodes } - } - if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { - this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } - } - if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) { - this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) } - } - if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) { - this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) } - } if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } - if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { - this.queueCache = { rev: this.queueRev, value: this.queued } - } - const partial = this.partial?.toPartial() ?? null + const chat = (this.conversation.snapshot('chat') as ChatSnapshot | undefined) ?? EMPTY_CHAT_SNAPSHOT + const legacy = chat.legacy return { sessionId: this.sessionId, - nodes: this.toolCallTree.projectNodes(nodes), - turnTimings: this.turnTimingsCache.value, - turnEnds: this.turnEndsCache.value, - partial, - runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value), + chat, + nodes: legacy.nodes, + turnTimings: legacy.turnTimings, + turnEnds: legacy.turnEnds, + partial: legacy.partial, + runningCalls: legacy.runningCalls, pending: this.pendingCache.value, - queue: this.queueCache.value, + queue: this.queueMirror.snapshot(), running: this.running, subagent: this.address === undefined ? null : { address: this.address, parentAvailable: this.parentAvailable }, composerPhase: derivePhase( - // Command lifecycle nodes are not conversation: running /permission - // or /plan on a fresh session keeps the hero (the client mirror of - // the host's no-turn sessionBlank predicate). - nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0, + (!this.blankBit && !this.firstPromptPendingTurn) + || this.running + || this.pendingCache.value.length > 0, this.promptAttempted, ), removed: this.removed, @@ -985,67 +728,18 @@ export class Session implements SessionFace { } } -/** Validate the plugin-owned payload at the session-event wire boundary. */ -function parseRetryEventData(value: unknown): LlmRetryEventData | null { - if (value === null || typeof value !== 'object') return null - const data = value as Record - const failure = data.failure - if (failure === null || typeof failure !== 'object') return null - const failureData = failure as Record - if (!nonNegativeSafeInteger(data.turn) - || !nonNegativeSafeInteger(data.step) - || typeof data.provider !== 'string' - || data.provider.length === 0 - || typeof data.policyKey !== 'string' - || data.policyKey.length === 0 - || !positiveSafeInteger(data.retry) - || typeof data.delayMs !== 'number' - || !Number.isFinite(data.delayMs) - || data.delayMs < 0 - || data.delayMs > MAX_RETRY_DELAY_MS - || typeof failureData.message !== 'string' - || failureData.message.length === 0 - || typeof failureData.code !== 'string' - || failureData.code.length === 0) return null - if (data.mode === 'normal') { - if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null - } else if (data.mode === 'always') { - if ('maxRetries' in data) return null - } else { - return null - } - if (failureData.status !== undefined - && (typeof failureData.status !== 'number' - || !Number.isInteger(failureData.status) - || failureData.status < 100 - || failureData.status > 599)) return null - if (failureData.providerRetryAfterMs !== undefined - && (typeof failureData.providerRetryAfterMs !== 'number' - || !Number.isFinite(failureData.providerRetryAfterMs) - || failureData.providerRetryAfterMs <= 0)) return null - if (failureData.requestId !== undefined - && (typeof failureData.requestId !== 'string' - || failureData.requestId.length === 0)) return null - return data as unknown as LlmRetryEventData -} - -function nonNegativeSafeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 -} - -function positiveSafeInteger(value: unknown): value is number { - return nonNegativeSafeInteger(value) && value > 0 +/** Convert one wire history row into the assembler's transport-neutral input. */ +function conversationInput(entry: HistoryEntry): ConversationEventInput { + return { event: entry.event, view: entry.view } } /** * The composerPhase judgment — the single site that knows the predicate - * (consumers switch on the result, never re-derive). Monotone per session - * object: `hasContent` only grows within a window and `promptAttempted` is - * sticky, so blank → engaging → active never steps back; a failed first - * prompt stays engaging (retry semantics — see ComposerPhase). - * @param hasContent - any conversation material exists (non-command nodes, - * partial, running turn, pending waits; command lifecycle rows alone keep - * the session blank). + * (consumers switch on the result, never re-derive). A failed first prompt + * stays engaging until an authoritative accepted-turn, running, or pending + * signal arrives (retry semantics — see ComposerPhase). + * @param hasContent - authoritative non-blank activity beyond a pending first + * prompt, a running turn, or a pending interaction. * @param promptAttempted - a prompt was initiated on this session object. * @returns the derived phase. */ diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts deleted file mode 100644 index 78090ce87f..0000000000 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ /dev/null @@ -1,409 +0,0 @@ -// TranscriptAdapter: the human transcript projected from the raw event window -// in LOG order. The model-visible surface deliberately shadows replaced ranges, -// so it is the wrong source for conversation a reader already saw; this adapter -// keeps every append-origin event at its own log position and contributes one -// marker node per landed compaction checkpoint. Node order is therefore -// seq-monotonic by construction — no surface fold, no padding sentinels, no -// seq === index assertion to satisfy, and no degradation branch. - -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -// Subpath export (package.json exports "./surface", alias added for this): all value imports -// go through it — the package root points at lib/index.js (needs a build) which the vite -// browser bundle cannot resolve; surface.ts has no Node dependencies. -import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -// Cordis-free leaf subpath (the dsh-commands/brand shape): the Service Definition's -// declaration of the checkpoint source, reachable as a TYPE from this program. -// The package ROOT is not — it reaches dsh-session's root, whose Context merge -// declares the HOST `sessions: SessionStore` against this program's -// `sessions: ISessions` (TS2717, the one-program-per-side rule in -// docs/development.md). -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' -import { toAssistantBlocks } from './conversation.ts' -import { contextForm, contextProvenance } from './context-provenance.ts' -import { SteeringHistory } from './steering-history.ts' -import type { AssistantStepMetadata } from './assistant-timing.ts' -import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts' - -/** - * The compaction capability's checkpoint plugin, pinned to the Service Definition's declaration - * at COMPILE time: renaming it there fails this annotation (`TS2322`). The - * import stays type-only because a value import would fail the client purity - * gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are - * forbidden in a browser bundle — while an erased type never reaches it. - */ -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' - -/** In-window tool/call index entry used to materialize result cards. */ -interface CallIndexEntry { - name: string - argsRaw: string - turn: number - step: number - /** Unix epoch ms of the tool/call event. */ - time: number - /** Wire view riding the tool/call (envelope-level; never inside the event). */ - callView: ToolCallView | null -} - -/** One event -> UI node (pure function; the ten-variant ConversationNode union). */ -function materializeNode( - event: SessionEvent, - callIndex: ReadonlyMap, - resultView: ToolResultView | null, - steering: boolean, - stepTimings: ReadonlyMap, -): ConversationNode { - switch (event.type) { - case 'user/message': { - // Injected context (plugin/goal/skill-invocation source) folds to a - // context node, not a user message; only a direct human prompt is a - // user node. A compaction checkpoint never reaches here - // (isCompactCheckpoint routes it away). - if (event.data.source.kind !== 'user') { - return { - kind: 'context', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - provenance: contextProvenance(event.data.source), - form: contextForm(event.data.source), - } - } - if (steering) { - return { - kind: 'steering', messageId: event.data.id, - seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - } - return { - kind: 'user', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - } - case 'assistant/message': - return { - kind: 'assistant', seq: event.seq, time: event.time, - turn: event.data.turn, step: event.data.step, - blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, - timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time), - } - case 'tool/result': { - const result = event.data.message.content[0] - const callId = String(event.data.message.source.callId) - const call = callIndex.get(callId) - return { - kind: 'tool-result', seq: event.seq, time: event.time, - callId, - call: call ? { name: call.name, argsRaw: call.argsRaw } : null, - callTime: call?.time ?? null, - content: result.content, isError: result.isError === true, - ...(event.data.error !== undefined ? { error: event.data.error } : {}), - meta: event.data.meta, - callView: call?.callView ?? null, - resultView, - subCalls: [], - } - } - /* v8 ignore next 2 -- defensive arm: only the four surface-eligible types - can be append-origin, and each has a case above; reachable only if core - adds an eligible type. */ - default: - return { - kind: 'unknown', seq: event.seq, time: event.time, - type: event.type, data: (event as { data?: unknown }).data, - } - } -} - -/** - * Whether an event is a landed compaction checkpoint — all three conditions, - * matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the - * compaction seam's checkpoint plugin source, that REPLACED a surface range. A - * plugin-sourced `user/message` that appends is injected context (a - * session-reference card), not a compaction; a replacement `tool/result` is an - * in-place prune and a replacement `assistant/message` a generic rewrite, and - * both mark no boundary in the conversation. - * @param event - the raw window event. - * @returns true when the event compacted a surface range. - */ -function isCompactCheckpoint(event: SessionEvent): boolean { - if (event.type !== 'user/message') return false - const source = event.data.source - return source.kind === 'plugin' && source.plugin === COMPACT_PLUGIN - && isReplacementSurfaceEvent(event) -} - -/** Whether an event contributes a node to the human transcript. */ -function isTranscriptEvent(event: SessionEvent): boolean { - return isAppendSurfaceEvent(event) || isCompactCheckpoint(event) -} - -/** - * Concatenated text of a `compact/summary` payload, or null when it carries no - * usable text. The payload is a `ContentBlock[]` whose union is - * merge-extensible, so a non-text block is skipped rather than discarding the - * text beside it; a payload with no text block at all falls to null through the - * empty check. - */ -function compactSummaryText(event: SessionEvent): string | null { - const summary = (event.data as unknown as { summary?: unknown }).summary - if (!Array.isArray(summary)) return null - let text = '' - for (const block of summary as readonly unknown[]) { - const candidate = block as { type?: unknown; text?: unknown } - if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue - text += candidate.text - } - return text.trim() === '' ? null : text -} - -interface CompactSummaryDetails { - readonly summary: string | null - readonly shadowedItemCount: number | null - readonly shadowedTokenCount: number | null -} - -/** Recover human-facing summary material from one structurally narrowed wire event. */ -function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails { - const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown } - const shadowedSeqs = data.shadowedSeqs - const tokenCount = data.shadowedTokenCount - return { - summary: compactSummaryText(event), - shadowedItemCount: Array.isArray(shadowedSeqs) - && shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0) - ? shadowedSeqs.length - : null, - shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0 - ? tokenCount as number - : null, - } -} - -/** - * One landed checkpoint -> the human-facing compaction marker. The summary text - * comes from the checkpoint's cited `compact/summary` event (`sourceEventSeqs` names the - * `compact/summary` event), never from the framed checkpoint payload, which is - * an instruction envelope written for the model. A window cut that left the - * summary event outside soft-falls to `summary: null` (a non-expandable marker), - * the same posture as a call-less tool result. - */ -function materializeCompaction( - checkpoint: SessionEvent, - eventIndex: ReadonlyMap, -): CompactionSummaryNode { - const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs - let summary: string | null = null - let summaryEventSeq: number | null = null - let shadowedItemCount: number | null = null - let shadowedTokenCount: number | null = null - for (const seq of sources ?? []) { - const candidate = eventIndex.get(seq) - if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue - const details = compactSummaryDetails(candidate) - summary = details.summary - summaryEventSeq = candidate.seq - shadowedItemCount = details.shadowedItemCount - shadowedTokenCount = details.shadowedTokenCount - break - } - return { - kind: 'compaction', - seq: checkpoint.seq, - time: checkpoint.time, - summary, - summaryEventSeq, - shadowedItemCount, - shadowedTokenCount, - } -} - -/** Log-ordered human transcript over a paged raw event window (never consults surface order). */ -export class TranscriptAdapter { - /** Window events by seq, used to find the summary event cited by a checkpoint. */ - private eventIndex = new Map() - /** Transcript nodes in log order; copy-on-write so a published array never mutates. */ - private projected: ConversationNode[] = [] - private callIdx = new Map() - /** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */ - private stepTimings = new Map() - /** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */ - private resultViews = new Map() - /** Durable inbox replay used to distinguish next-step human input from queued prompts. */ - private readonly steeringHistory = new SteeringHistory() - /** - * Command lifecycle nodes by commandId (insertion = run order). The - * `command/run`/`command/done` pair is log-only, so it is not a surface - * event and never joins the transcript projection; this index folds the pair - * (done settles its run's node in place) and nodes() merges the products in - * by seq. Window cuts soft-fall like tool pairs: a done with no in-window - * run still builds a node. - */ - private commandIdx = new Map() - /** Projection revision, bumped only when a transcript node or a command node actually - * changed, keying the nodes() result cache: an unchanged projection returns the previous - * ARRAY reference, not just cached elements — the snapshot's reference-stability contract - * (§A.9.4) starts here, and a chunk storm bumps nothing at all. */ - private rev = 0 - private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null - - /** - * Window rebuild (after open/resync/page prepend): re-index the raw window - * and re-project the transcript. - * @param events - the new window contents (seq-ascending). - * @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events). - */ - reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void { - this.rev++ - this.eventIndex = new Map() - this.callIdx = new Map() - this.resultViews.clear() - this.commandIdx = new Map() - this.steeringHistory.reset() - const steeringSeqs = new Set() - this.stepTimings = new Map() - for (let i = 0; i < events.length; i++) { - const event = events[i] - /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ - if (event === undefined) continue - this.eventIndex.set(event.seq, event) - this.indexCall(event, views?.[i]) - this.indexCommand(event) - if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq) - indexAssistantStepTiming(this.stepTimings, event) - } - // Indexes first, then project: a tool/result materializes against the - // complete call index, and a checkpoint against the complete event index. - const projected: ConversationNode[] = [] - for (const event of events) { - if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq))) - } - this.projected = projected - } - - /** - * Tail append (live session/event): index the event and, when it belongs to - * the transcript, extend the projection by one copy-on-write node so a - * published array never mutates. An event that changes no node (a chunk - * storm) bumps no revision, so nodes() keeps returning the same array - * reference. - * @param event - the live event (seq = window tail + 1). - * @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering. - */ - append(event: SessionEvent, view?: ToolEventView): void { - this.eventIndex.set(event.seq, event) - this.indexCall(event, view) - const steering = this.steeringHistory.apply(event) - indexAssistantStepTiming(this.stepTimings, event) - if (this.indexCommand(event)) this.rev++ - if (!isTranscriptEvent(event)) return - this.projected = [...this.projected, this.materialize(event, steering)] - this.rev++ - } - - /** - * The current transcript node array. Same revision -> same array reference - * (memo boundary); node objects are materialized once, so an unchanged node - * keeps its identity across appends. - * @returns transcript nodes in log order, command nodes merged in by seq. - */ - nodes(): readonly ConversationNode[] { - if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value - // Command nodes fold outside the transcript (log-only events); merge by - // seq. Both inputs are seq-ascending (log order and run-index insertion - // order are the same order), so one linear merge keeps flow order. - let nodes = this.projected - if (this.commandIdx.size > 0) { - nodes = [] - const commands = [...this.commandIdx.values()] - let next = 0 - for (const node of this.projected) { - for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) { - nodes.push(cmd) - } - nodes.push(node) - } - for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd) - } - this.nodesResult = { rev: this.rev, value: nodes } - return nodes - } - - /** Materialize one transcript event against the complete current indexes. */ - private materialize(event: SessionEvent, steering: boolean): ConversationNode { - return isCompactCheckpoint(event) - ? materializeCompaction(event, this.eventIndex) - : materializeNode( - event, - this.callIdx, - this.resultViews.get(event.seq) ?? null, - steering, - this.stepTimings, - ) - } - - /** - * Fold one command lifecycle event into its node (run mints, done settles in - * place; done-only soft-falls). - * @returns whether the command index changed, so callers can bump the revision. - */ - private indexCommand(event: SessionEvent): boolean { - // Log-only plugin events: the host-side dsh-commands declaration cannot - // enter the client program, so this wire consumer narrows structurally - // (the same posture as tool/code-dispatch in session.ts). - if ((event.type as string) === 'command/run') { - const data = event.data as unknown as { commandId: CommandId; name: string; args?: string } - this.commandIdx.set(data.commandId, { - kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null, - }) - return true - } - if ((event.type as string) !== 'command/done') return false - const data = event.data as unknown as { - commandId: CommandId - kind: 'success' | 'error' - text?: string - sourceEventSeq?: number - } - const run = this.commandIdx.get(data.commandId) - const sourceEventSeq = data.kind === 'success' - && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 - ? data.sourceEventSeq as number - : undefined - const outcome = { - kind: data.kind, - ...data.text === undefined ? {} : { text: data.text }, - ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, - } - if (run === undefined) { - // Cross-window cut: the run page fell out of the window — build the - // node from the done alone (same soft-fall as a call-less tool result). - this.commandIdx.set(data.commandId, { - kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: null, args: null, outcome, - }) - return true - } - // Settle in place: a fresh node object (published references stay immutable). - this.commandIdx.set(data.commandId, { ...run, outcome }) - return true - } - - private indexCall(event: SessionEvent, view?: ToolEventView): void { - if (event.type === 'tool/result') { - if (view?.for === 'result') this.resultViews.set(event.seq, view.view) - return - } - if (event.type !== 'tool/call') return - this.callIdx.set(String(event.data.callId), { - name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, - time: event.time, - callView: view?.for === 'call' ? view.view : null, - }) - // No backfill into already-materialized tool-result nodes for this callId - // (window order puts the call before its result; cannot happen on the normal path). - } -} diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index a30adb4dd6..e3ad848e05 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -320,7 +320,7 @@ export class SlotsService extends Service { const dispose = (this._core as unknown as ErasedCore).register(erased, component) if (store !== undefined) { // Register succeeded, so the target's spec is on the ledger. - const scope = (this._core.specDynamic(options.name) as SlotSpec).scope + const scope = (this._core.specDynamic(options.name) as SlotSpec).scope this._acquire(store, scope) } let disposed = false diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 7d7d9a6c34..4ed6ecf519 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -101,15 +101,40 @@ export interface SlotEntryDef { kind: SlotKind scope: SlotScope owner?: object + /** + * Optional keyed-entry prop table. A keyed registration contributes one + * literal key and receives the corresponding prop share; ordinary owner + * props remain common to every key. + */ + keyProps?: Record + /** + * Optional opaque context carried by one renderSlot occurrence. Only + * function-valued members of the slot-level injected hooks compartment + * receive it; the slot machinery never interprets the value. + */ + hookContext?: unknown + /** + * Optional Slot-level inject face supplied by the parent registration's + * child declaration. Every registered entry receives its bound component + * face; child registrants do not own or replace this common capability. + */ + inject?: object } /** * Runtime dispatch spec for one slot, recorded from a register call's * `children` value. The literal is compile-time checked against the SlotMap - * entry (`SlotSpec` in {@link ChildrenDecl}), so type and value - * are declared at one point and validate each other. + * entry (`SlotSpec` in {@link ChildrenDecl}), so kind, scope, and + * any common inject face are declared at one point and validate each other. */ -export interface SlotSpec { kind: E['kind']; scope: E['scope'] } +export type SlotSpec = { + kind: E['kind'] + scope: E['scope'] +} & ('inject' extends keyof E + ? E extends { inject: infer Injected extends object } + ? { inject: Injected } + : { inject?: object } + : { inject?: never }) /** * Child-slot declaration table for register(): keys are the declared (and @@ -123,6 +148,30 @@ export type ChildrenDecl = { [P in keyof SlotMap & string]?: SlotSpec = SlotMap[K] extends { owner: infer O extends object } ? O : object +/** Registration/dispatch key domain of one keyed slot. */ +export type EntryKeyOf = + SlotMap[K] extends { kind: 'keyed'; keyProps: infer P extends object } + ? keyof P & string + : string + +/** Key-dependent props supplied by the owner at one keyed dispatch site. */ +export type KeyPropsOf< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, +> = SlotMap[K] extends { kind: 'keyed'; keyProps: infer P extends object } + ? EntryKey extends keyof P + ? P[EntryKey] extends object ? P[EntryKey] : never + : never + : object + +/** Opaque per-render occurrence context declared by one slot. */ +export type HookContextOf = + SlotMap[K] extends { hookContext: infer Context } ? Context : never + +/** Common render-occurrence inject face declared by one slot. */ +export type SlotInjectOf = + SlotMap[K] extends { inject: infer Injected extends object } ? Injected : object + /** Scope axis of a slot key's SlotMap entry. */ export type ScopeOf = SlotMap[K]['scope'] @@ -159,15 +208,26 @@ export type SessionIdOf = SessionStandardProps extends { sessionId: infer S } ? * Runtime props share for a slot key: owner share (parent's renderSlot call * site) + session standard kit (session scope only) + the global seat. */ -export type PropsRuntime = +export type PropsRuntime< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf = EntryKeyOf, +> = OwnerOf & + KeyPropsOf & + SlotInjectFace> & (ScopeOf extends 'session' ? SessionStandardProps : ScopeOf extends 'session-maybe' ? SessionMaybeStandardProps : object) & GlobalStandardProps -/** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */ -export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode } +/** renderSlot dispatch options: keyed dispatch key, list filtering, and empty fallback. */ +export interface RenderOpts { + entryKey?: EntryKey + only?: string + fallback?: ReactNode + /** Type-erased runtime seat; PropsRenderSlots narrows or removes it per slot declaration. */ + hookContext?: unknown +} /** renderSlotChain dispatch options. */ export interface ChainRenderOpts { @@ -200,6 +260,40 @@ export type ChainSelect = (owner: O) => M | null export type ChainKeysOf = S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never +/** Keys in a render share whose dispatch occurrence requires hookContext. */ +type ContextualKeysOf = + S extends unknown ? (SlotMap[S] extends { hookContext: unknown } ? S : never) : never + +/** Keys in a render share with the ordinary optional options bag. */ +type OrdinaryKeysOf = Exclude> + +/** + * Plain and contextual child dispatch signatures. Keeping them as separate + * call signatures preserves ordinary renderSlot assignability while making a + * declared hookContext mandatory only for the Slot keys that need it. + */ +type RenderSlotFn = + ([ContextualKeysOf] extends [never] ? object : { + < + K extends ContextualKeysOf, + EntryKey extends EntryKeyOf = EntryKeyOf, + >( + key: K, + owner: OwnerOf & KeyPropsOf>, + opts: RenderOpts & { hookContext: HookContextOf }, + ): ReactNode + }) & + ([OrdinaryKeysOf] extends [never] ? object : { + < + K extends OrdinaryKeysOf, + EntryKey extends EntryKeyOf = EntryKeyOf, + >( + key: K, + owner: OwnerOf & KeyPropsOf>, + opts?: Omit, 'hookContext'>, + ): ReactNode + }) + /** * Chain matched share: a chain-slot component receives its selector's * non-null result as the framework-injected `matched` prop; other kinds add @@ -248,7 +342,7 @@ export type PropsRenderSlots = { * @param opts - kind dispatch options. * @returns rendered node(s). */ - renderSlot: >>(key: K, owner: OwnerOf, opts?: RenderOpts) => ReactNode + renderSlot: RenderSlotFn>> readonly __renders?: ((key: S) => void) | undefined } & ([ChainKeysOf] extends [never] ? object : { /** @@ -277,19 +371,53 @@ export type SlotComponent

= (props: P) => ReactNode /** * Registrant hooks compartment: bare observable sources (getSnapshot + - * subscribe pairs) supplied under the reserved `hooks` key of an inject - * face. The registrant-private twin of the `sessions.provide` hooks - * compartment: the renderer binds each source into a `use` selector - * hook, so the sources never reach the component and plugin-private reactive - * facts ride the same subscription machinery as the standard kit instead of - * hand-rolled component subscriptions. + * subscribe pairs) supplied under the reserved `hooks` key of an entry's + * inject face. These retain the original source-to-selector binding and do + * not participate in render-occurrence context. */ export type HooksSources = Record> +/** Framework-owned props visible while a slot-level contextual Hook is bound. */ +export type StandardPropsOf = + (ScopeOf extends 'session' ? SessionStandardProps + : ScopeOf extends 'session-maybe' ? SessionMaybeStandardProps + : object) & + GlobalStandardProps + +/** + * One function-valued slot-level inject.hooks member. The factory is pure and + * returns the actual custom Hook; it must not invoke a Hook while being bound. + */ +export type SlotHookFactory< + K extends keyof SlotMap & string, + Hook extends (...args: never[]) => unknown, +> = ( + standard: StandardPropsOf, + hookContext: HookContextOf, +) => Hook + +/** Component-side Hook produced from one slot-level inject.hooks member. */ +type BoundHookOf = + Definition extends HostObservable + ? SnapshotSelectorHook + : Definition extends (...args: never[]) => infer Hook + ? Hook extends (...args: never[]) => unknown ? Hook : never + : never + /** * Selector-hook share synthesized from a hooks compartment: each source * `name` becomes a `use` selector hook over its snapshot type. */ +export type PropsSlotHooks = { + [N in keyof HS & string as `use${Capitalize}`]: + BoundHookOf +} + +/** Component-side view of a slot dispatcher's common inject face. */ +export type SlotInjectFace = + I extends { hooks: infer HS extends object } ? Omit & PropsSlotHooks : I + +/** Selector-hook share synthesized from an entry inject hooks compartment. */ export type PropsHooks = { [N in keyof HS & string as `use${Capitalize}`]: SnapshotSelectorHook ? T : never> @@ -313,12 +441,13 @@ export type InjectFace = */ export type ComposedProps< K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, S extends keyof SlotMap & string, H, I extends object, M = never, N = undefined, -> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale /** * Inject factory parameter list, derived from the registration's declaration: @@ -345,12 +474,16 @@ export type InjectParams = export type SlotLabel = string | (() => string) /** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */ -export type KindOptions = - E['kind'] extends 'keyed' ? { key: string } - : E['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel } - : E['kind'] extends 'chain' ? { +export type KindOptions< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, + M = never, +> = + SlotMap[K]['kind'] extends 'keyed' ? { key: EntryKey } + : SlotMap[K]['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel } + : SlotMap[K]['kind'] extends 'chain' ? { /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */ - select: ChainSelect + select: ChainSelect /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */ priority?: number } @@ -372,7 +505,14 @@ type RendersCheck = : unknown /** Common register options share (see {@link SlotCore.register} for semantics). */ -type BaseOptions = { +type BaseOptions< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, + D extends ChildrenDecl, + H, + M = never, + N = undefined, +> = { /** Target slot key (the entry contributes INTO this slot). */ name: K /** Child-slot declaration + render authorization + runtime spec, in one table. */ @@ -388,7 +528,7 @@ type BaseOptions +} & KindOptions /** * One stored registration, as recorded by the core and read by the render @@ -528,15 +668,16 @@ export class SlotCore { * would lose the per-overload inference of I. */ register< K extends keyof SlotMap & string, + const EntryKey extends EntryKeyOf = EntryKeyOf, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject?: undefined }, + options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> & RendersCheck, ): () => void /** @@ -552,15 +693,16 @@ export class SlotCore { register< K extends keyof SlotMap & string, I extends object, + const EntryKey extends EntryKeyOf = EntryKeyOf, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject: (...args: InjectParams) => I }, + options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> & RendersCheck, ): () => void /* jscpd:ignore-end */ diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 2a676fa22a..20f6a68eb1 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -87,11 +87,13 @@ export interface SessionProvideInfo extends SessionMaybeProvideInfo { hooks: Record> } -/** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */ +/** renderSlot dispatch options at the machinery level. */ export interface RenderOpts { entryKey?: string only?: string fallback?: ReactNode + /** Opaque occurrence context consumed only by function-valued injected Hooks. */ + hookContext?: unknown } /** Host surface the runtime SlotsService presents to the installed renderer. */ diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index f7a3c32e93..69d37f35b4 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -2,7 +2,7 @@ * React renderer for declarative slots. Per-entry bindings enforce child * authorization, and entry boundaries contain registrant failures. */ -import { Component, useState, useSyncExternalStore, type FC, type ReactNode } from 'react' +import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts, @@ -16,6 +16,14 @@ import { type InjectedProps = Record +type SlotHookFactory = (standard: InjectedProps, hookContext: unknown) => unknown +type SlotHookFactories = Readonly> + +interface BoundSlotInject { + readonly props: InjectedProps + readonly slotHookFactories?: SlotHookFactories | undefined +} + type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode @@ -89,9 +97,11 @@ const rootInjectCache = new WeakMap() const sessionInjectCache = new WeakMap>() const sessionMaybeInjectCache = new WeakMap>() +const EMPTY_INJECTED_PROPS: InjectedProps = {} + function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps { const inject = entry.inject - if (!inject) return {} + if (!inject) return EMPTY_INJECTED_PROPS // Declaration-derived positional arguments: sessionId for session scope, // baked actions when a store is declared. const args: unknown[] = [] @@ -101,11 +111,8 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined } /** - * Bind an inject face's reserved `hooks` compartment (bare observable - * sources, see HooksSources) into `use` selector hooks — the - * registrant-private twin of the provide-bundle binding in standardKit. - * Runs once per cached inject result; hook identity rides observableHook's - * per-source cache. + * Normalize one entry-owned inject face on its existing cache axis. Its hooks + * compartment remains the original Observable-only contract. */ function bindInjectHooks(face: InjectedProps): InjectedProps { const sources = face['hooks'] @@ -119,6 +126,53 @@ function bindInjectHooks(face: InjectedProps): InjectedProps { return bound } +const slotInjectCache = new WeakMap() +const EMPTY_SLOT_INJECT: BoundSlotInject = { props: EMPTY_INJECTED_PROPS } + +/** Normalize one dispatcher-owned inject face by its stable object identity. */ +function cachedSlotInject(face: object | undefined): BoundSlotInject { + if (face === undefined) return EMPTY_SLOT_INJECT + let bound = slotInjectCache.get(face) + if (bound !== undefined) return bound + const definitions = (face as InjectedProps)['hooks'] + if (definitions === undefined) { + bound = { props: face as InjectedProps } + slotInjectCache.set(face, bound) + return bound + } + const { hooks: _hooks, ...rest } = face as InjectedProps + const props: InjectedProps = rest + let factories: Record | undefined + for (const [name, definition] of Object.entries(definitions as Record)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + if (typeof definition === 'function') { + factories ??= {} + factories[name] = definition as SlotHookFactory + } else { + props[hookName] = observableHook(definition as HostObservable) + } + } + bound = factories === undefined + ? { props } + : { props, slotHookFactories: factories } + slotInjectCache.set(face, bound) + return bound +} + +/** Bind deferred slot-level factories for one stable renderSlot occurrence. */ +function bindSlotHookFactories( + factories: SlotHookFactories, + standard: InjectedProps, + hookContext: unknown, +): InjectedProps { + const hooks: InjectedProps = {} + for (const [name, factory] of Object.entries(factories)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + hooks[hookName] = factory(standard, hookContext) + } + return hooks +} + function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps { let props = rootInjectCache.get(entry) if (!props) { @@ -270,6 +324,54 @@ class SlotErrorBoundary extends Component< } } +interface StandardPropsCache { + readonly root: InjectedProps + readonly session: WeakMap + readonly sessionMaybe: WeakMap +} + +const standardPropsCache = new WeakMap() + +/** Stable official-props object used by contextual Hook factories. */ +function standardProps( + host: SlotRendererHost, + scope: SlotScope, + info: SessionMaybeProvideInfo | undefined, +): InjectedProps { + let cache = standardPropsCache.get(host) + if (cache === undefined) { + cache = { + root: { + useSessions: observableHook(host.sessions.list), + useWorkspaces: observableHook(host.workspaces.list), + }, + session: new WeakMap(), + sessionMaybe: new WeakMap(), + } + standardPropsCache.set(host, cache) + } + if (scope === 'root') return cache.root + if (info === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without session provide info`) + const byInfo = scope === 'session' ? cache.session : cache.sessionMaybe + let standard = byInfo.get(info) + if (standard !== undefined) return standard + standard = { ...cache.root } + for (const [name, source] of Object.entries(info.hooks)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + if (scope === 'session-maybe') { + standard[hookName] = maybeObservableHook(source) + } else { + if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`) + standard[hookName] = observableHook(source) + } + } + Object.assign(standard, info.props) + standard['sessionId'] = info.sessionId + standard['useProjection'] = projectionHook(info) + byInfo.set(info, standard) + return standard +} + /** * Standard-kit synthesis shared by both scope branches: the global * useSessions/useWorkspaces hooks, the per-session provide bundle (every @@ -289,28 +391,11 @@ function standardKit( info: SessionMaybeProvideInfo | undefined, ): { kit: InjectedProps + standard: InjectedProps actions: object | undefined } { - const kit: InjectedProps = { - useSessions: observableHook(host.sessions.list), - useWorkspaces: observableHook(host.workspaces.list), - } - if (scope !== 'root' && info !== undefined) { - for (const [name, source] of Object.entries(info.hooks)) { - const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` - if (scope === 'session-maybe') { - kit[hookName] = maybeObservableHook(source) - } else { - if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`) - kit[hookName] = observableHook(source) - } - } - Object.assign(kit, info.props) - kit['sessionId'] = info.sessionId - // The useProjection seat (fifth framework hook): key-addressed cell - // reader, bound per provide bundle (cached by info identity). - kit['useProjection'] = projectionHook(info) - } + const standard = standardProps(host, scope, info) + const kit: InjectedProps = { ...standard } if (entry.locale !== undefined) { const face = host.locale // Loud assembly failure: locale is immediately-tier infrastructure; a @@ -344,38 +429,98 @@ function standardKit( kit['SessionProvider'] = SessionProvider } } - return { kit, actions: store?.actions } + return { kit, standard, actions: store?.actions } } /** - * One rendered entry: standard kit + cached inject + owner props (owner - * wins). The kit and injected shares are erased at the render boundary — the - * registration contract already proved the composed type — so each Entry renders - * through a props-widened view of the component (the design-budgeted - * composition point, one per scope branch). + * One rendered entry: standard kit + cached entry inject + common slot inject + * + owner props (owner wins). The shares are erased at this render boundary; + * the registration and renderSlot seams already proved their contracts. */ -function SessionEntry({ entry, ownerProps, info }: { +function ContextualEntry({ + slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext, +}: { + slotKey: string + Comp: FC + kit: InjectedProps + standard: InjectedProps + injected: InjectedProps + slotInjected: BoundSlotInject & { readonly slotHookFactories: SlotHookFactories } + ownerProps: object + hookContext: unknown + hasHookContext: boolean +}) { + const contextual = useMemo( + () => { + if (!hasHookContext) { + throw new SlotAssemblyError(`slot '${slotKey}' has contextual injected Hooks but no hookContext`) + } + return bindSlotHookFactories(slotInjected.slotHookFactories, standard, hookContext) + }, + [hasHookContext, hookContext, slotInjected.slotHookFactories, slotKey, standard], + ) + return +} + +function renderEntry( + slotKey: string, + Comp: FC, + kit: InjectedProps, + standard: InjectedProps, + injected: InjectedProps, + slotInjected: BoundSlotInject, + ownerProps: object, + hookContext: unknown, + hasHookContext: boolean, +): ReactNode { + if (slotInjected.slotHookFactories === undefined) { + return + } + return ( + + ) +} + +function SessionEntry({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: { entry: StoredEntry ownerProps: object info: SessionProvideInfo + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean }) { const host = useHost() const Comp = entry.component as FC - const { kit, actions } = standardKit(host, entry, 'session', info) + const { kit, standard, actions } = standardKit(host, entry, 'session', info) const injected = cachedSessionInject(entry, info, actions) - return + return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } -function SessionMaybeEntryBody({ entry, ownerProps, info }: { +function SessionMaybeEntryBody({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: { entry: StoredEntry ownerProps: object info: SessionMaybeProvideInfo + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean }) { const host = useHost() const Comp = entry.component as FC - const { kit, actions } = standardKit(host, entry, 'session-maybe', info) + const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', info) const injected = cachedSessionMaybeInject(entry, info, actions) - return + return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } /** @@ -391,7 +536,14 @@ function SessionMaybeEntryBody({ entry, ownerProps, info }: { * that must SURVIVE a switch belongs in session-bound sources (machine, * store, hooks) — the existing layering rule, now load-bearing. */ -function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) { +function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext }: { + entry: StoredEntry + ownerProps: object + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean +}) { const info = useSessionMaybeProvideInfo() // The child key is an incarnation counter, NOT the session id: adoption // must keep the key constant across undefined → first id. Bookkeeping @@ -416,7 +568,18 @@ function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerPro epoch += 1 setState({ adopted, epoch }) } - return + return ( + + ) } /** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */ @@ -429,24 +592,42 @@ interface MaybeIncarnation { const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 } -function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) { +function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext }: { + entry: StoredEntry + ownerProps: object + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean +}) { const host = useHost() const Comp = entry.component as FC - const { kit, actions } = standardKit(host, entry, 'root', undefined) + const { kit, standard, actions } = standardKit(host, entry, 'root', undefined) const injected = cachedRootInject(entry, actions) - return + return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } -function StrictSessionEntry({ slotKey, entry, ownerProps }: { +function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext }: { slotKey: string entry: StoredEntry ownerProps: object + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean }) { const info = useSessionMaybeProvideInfo() if (info.sessionId === undefined) return null return ( - + ) } @@ -478,31 +659,62 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { // An absent strict overlay chain follows its ordinary empty-election path, // preserving the Fragment/fallback-wrapper shape across session arrival. const entries = strictSessionAbsent ? [] : host.entriesOf(slotKey) + const slotInjected = cachedSlotInject(spec.inject) // The boundary must wrap the Entry ELEMENT, not live inside it: inject // factories and kit synthesis run in the Entry body and must land in the // per-entry fallback rather than escaping to the tree above. - const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => ( - spec.scope === 'session' - ? + const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => { + const hasHookContext = opts !== undefined && Object.hasOwn(opts, 'hookContext') + const hookContext = opts?.hookContext + return spec.scope === 'session' + ? ( + + ) : ( {spec.scope === 'session-maybe' - ? - : } + ? ( + + ) + : ( + + )} ) - ) + } if (spec.kind === 'single') { const entry = entries[0] if (!entry) return <>{opts?.fallback ?? null} - return guarded(entry) + return guarded(entry, entryKeyOf(entry)) } if (spec.kind === 'keyed') { const entry = entries.find(e => e.options.key === opts?.entryKey) if (!entry) return <>{opts?.fallback ?? null} - return guarded(entry) + return guarded(entry, entryKeyOf(entry)) } if (spec.kind === 'chain') { // Entries arrive priority-sorted from the ledger (the core orders at @@ -561,7 +773,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { let list = [...withListOptions].sort((a, b) => a.order - b.order) if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only) if (list.length === 0) return <>{opts?.fallback ?? null} - return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))} + return <>{list.map(item => guarded(item.entry, entryKeyOf(item.entry)))} } /** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank (§1). */ @@ -575,8 +787,15 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) { const entry = host.entriesOf('root')[0] if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)") return ( - - + + ) } From 6d09b3168d035f7668ea73b745a115be1dc7eaac Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:48:40 +0800 Subject: [PATCH 07/32] refactor(ui-conversation): register business node definitions --- packages/client/ui-conversation/package.json | 2 + .../ui-conversation/src/client/apply.ts | 36 +- .../src/client/chat/AssistantMarkdown.tsx | 72 +-- .../src/client/chat/AssistantNodeView.tsx | 32 ++ .../src/client/chat/ChatNodeSeat.tsx | 60 +++ .../src/client/chat/ChatView.module.css | 14 +- .../src/client/chat/ChatView.tsx | 303 ++---------- .../src/client/chat/CommandNodeView.tsx | 40 ++ .../src/client/chat/MessageItem.tsx | 126 ++--- .../src/client/chat/StatsLine.tsx | 4 +- .../client/chat/TurnTailNodeView.module.css | 9 + .../src/client/chat/TurnTailNodeView.tsx | 43 ++ .../src/client/chat/chat-flow.ts | 214 -------- .../client/chat/register-node-renderers.ts | 46 ++ .../src/client/chat/tool-node-reader.ts | 46 ++ .../src/client/chat/turn-assistant.ts | 10 + .../src/client/chat/turn-metrics.ts | 6 +- .../src/client/contract/chat-nodes.ts | 82 ++++ .../src/client/contract/slots.ts | 93 ++-- .../client/conversation-nodes/assistant.ts | 316 ++++++++++++ .../chat-snapshot-builder.ts | 456 ++++++++++++++++++ .../src/client/conversation-nodes/command.ts | 243 ++++++++++ .../src/client/conversation-nodes/common.ts | 55 +++ .../client/conversation-nodes/compaction.ts | 63 +++ .../src/client/conversation-nodes/fallback.ts | 40 ++ .../src/client/conversation-nodes/inbox.ts | 71 +++ .../src/client/conversation-nodes/message.ts | 83 ++++ .../src/client/conversation-nodes/register.ts | 30 ++ .../src/client/conversation-nodes/retry.ts | 116 +++++ .../src/client/conversation-nodes/tool.ts | 271 +++++++++++ .../client/conversation-nodes/turn-error.ts | 112 +++++ .../client/conversation-nodes/turn-tail.ts | 180 +++++++ .../ui-conversation/src/client/index.ts | 19 +- .../src/client/skeleton/ApprovalPanel.tsx | 9 +- .../src/client/skeleton/DetailsPanel.tsx | 27 +- .../client/ui-conversation/src/invariant.ts | 2 +- .../ui-deliverables/src/client/index.ts | 7 +- .../src/client/turn-deliverables.ts | 139 ++++-- packages/client/ui-tool/src/client/apply.ts | 5 +- .../ui-tool/src/client/contract/slots.ts | 4 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 3 +- 41 files changed, 2744 insertions(+), 745 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx delete mode 100644 packages/client/ui-conversation/src/client/chat/chat-flow.ts create mode 100644 packages/client/ui-conversation/src/client/chat/register-node-renderers.ts create mode 100644 packages/client/ui-conversation/src/client/chat/tool-node-reader.ts create mode 100644 packages/client/ui-conversation/src/client/chat/turn-assistant.ts create mode 100644 packages/client/ui-conversation/src/client/contract/chat-nodes.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/command.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/common.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/message.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/register.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/retry.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/tool.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 2ff87cc0e2..3f5bd8364c 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7", @@ -53,6 +54,7 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 0e486e9154..6d8e8e8c67 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -7,8 +7,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ViewTab } from './contract/views.ts' import type { - ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected, - ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected, + ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, + ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, + DetailsInjected, } from './contract/slots.ts' import type { InputNotice } from './input/contract.ts' import { createChatStore } from './stores.ts' @@ -30,6 +31,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' +import { registerConversationNodes } from './conversation-nodes/register.ts' +import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -39,7 +42,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] +export const inject = [ + 'slots', 'layout', 'sessions', 'workspaces', 'locale', + 'conversationEvents', 'conversationViews', +] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -63,6 +69,19 @@ const ABSENT_MENU_LAUNCHER = { subscribe: () => () => {}, } +const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { + hooks: { + turnData: ({ useSession }, nodeKey) => function useTurnData(key) { + return useSession((snapshot) => { + const location = snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(key) + : undefined + }) + }, + }, +} + /** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { const scoped = sessions.scope(id) @@ -86,6 +105,9 @@ export function apply(ctx: Context): void { const layout = ctx.layout const slots = ctx.slots + registerConversationNodes(ctx) + registerChatNodeRenderers(ctx) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') // Registration-time text (the view tab label) reads through the bound @@ -296,8 +318,8 @@ export function apply(ctx: Context): void { slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel) // The chat view: first entry of the ring this package just declared. - // ChatView owns ordered Tool placement but delegates each whole root call - // to ui-tool, which owns root/subcall composition and atomic dispatch. + // ChatView owns only the stable ordered Node list. Business renderers are + // independently keyed behind its one Node seat. slots.register({ name: 'conversation.view', id: 'chat', @@ -305,9 +327,7 @@ export function apply(ctx: Context): void { label: () => t('view.chat'), locale: NS, children: { - 'conversation.chat.tool': { kind: 'single', scope: 'session' }, - 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, - 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, + 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index d8883be4cd..6dd9f09b4d 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -11,12 +11,9 @@ import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' -import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts' -import { hasContentText } from './chat-flow.ts' -import { MessageIconActions } from './MessageIconActions.tsx' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -25,61 +22,19 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined - /** Unix epoch ms for the IconActions clock; omitted while streaming or when - * the parent withholds chrome (mid-turn content assistants and every node - * of a turn that has not ended). */ - time?: number | undefined - /** Turn wall time in ms for the IconActions run-time label; omitted when the - * turn's triggering input is outside the loaded window. */ - runMs?: number | undefined - /** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */ - ttftMs?: number | undefined - /** Turn decode throughput for the IconActions label; omitted when unrecorded. */ - tokensPerSecond?: number | undefined - /** Event sequence used as the fork boundary; omitted while streaming. */ - seq?: number | undefined - /** Fork the session through this finalized message's completed turn when eligible. */ - onFork?: ((seq: number) => void) | undefined - /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ - turnTail?: (Pick, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined - /** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */ - fileMentions?: ChatViewInjected['fileMentions'] | undefined - /** The message is not the transcript tail of a completed turn. */ - forkUnavailable?: boolean | undefined + /** Resolved prose file mentions for this Assistant's closing turn. */ + mentions?: MarkdownFileMentions | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } -/** Joined text blocks for the copy action (reasoning / tool heads stay out). */ -function copyText(blocks: readonly AssistantBlock[]): string { - const parts: string[] = [] - for (const block of blocks) { - if (block.kind === 'text') parts.push(block.text) - } - return parts.join('') -} - /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, - fileMentions, t, + blocks, streaming, interrupted, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) - // Mention vocabulary for the closing prose. Keyed on the anchor seq, not the - // growing transcript: a settled turn's produced files are final, and a - // fresh identity per append would discard MarkdownText's cached parse for - // every settled closing message on every stream chunk. The window-prepend - // edge (a mid-turn window start later gaining earlier same-turn writes) - // leaves a mention unlinked until remount — never a wrong link. - const owner = turnTail?.owner - const mentions: MarkdownFileMentions | undefined = useMemo( - () => (owner === undefined ? undefined : fileMentions?.(owner)), - // Deliberately not `owner`: its identity changes per append while the - // seq-addressed vocabulary it yields does not. - [fileMentions, owner?.seq], - ) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -88,10 +43,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ || interrupted === true || blocks.some(block => block.kind !== 'tool-call') if (!hasVisible) return null - // Footer only under settled content text; Think-only / streaming omit it. - const showActions = !streaming && time !== undefined && hasContentText(blocks) return ( -

+
{blocks.map((block, i) => { switch (block.kind) { @@ -119,21 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && {t('message.stopped')}}
- {showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)} - {showActions && ( - { onFork(seq) }} - branchUnavailable={forkUnavailable} - className={css.actions} - t={t} - /> - )}
) }) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx new file mode 100644 index 0000000000..d0f8b33e6f --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx @@ -0,0 +1,32 @@ +import { memo, useMemo } from 'react' +import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' +import { AssistantMarkdown } from './AssistantMarkdown.tsx' + +/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ +export const AssistantNodeView = memo(function AssistantNodeView({ + node, useTurnData, openFile, fileMentions, t, +}: ChatNodeViewProps<'assistant-step'>) { + const data = node.data + const turn = node.location.kind === 'turn' || node.location.kind === 'step' + ? node.location.turn + : undefined + const tail = useTurnData('turn-tail') + const owner = useMemo(() => { + if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined + if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined + return { turn, seq: data.finalNode.seq, openFile } + }, [data.finalNode, openFile, tail, turn]) + const mentions = useMemo( + () => owner === undefined ? undefined : fileMentions(owner), + [fileMentions, owner], + ) + return ( + + ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx new file mode 100644 index 0000000000..1f7a2f7451 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx @@ -0,0 +1,60 @@ +import { memo, useMemo } from 'react' +import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNode } from '../contract/chat-nodes.ts' +import css from './ChatView.module.css' + +interface ChatNodeSeatProps extends ChatNodeOwnerProps { + readonly nodeKey: string + readonly useSession: ChatViewSlotProps['useSession'] + readonly renderSlot: ChatViewSlotProps['renderSlot'] + readonly t: ChatViewSlotProps['t'] +} + +type RoutedChatNodeOwner = { + [Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode } +}[ChatNode['kind']] + +/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ +export const ChatNodeSeat = memo(function ChatNodeSeat({ + nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, + fileMentions, useSession, renderSlot, t, +}: ChatNodeSeatProps) { + const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) + const routedNode = node as ChatNode | undefined + const owner = useMemo(() => node === undefined + ? null + : { + selectedCallId, + cwd, + openFile, + inspectCall, + forkAt, + fileMentions, + }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, fileMentions]) + if (routedNode === undefined || owner === null) return null + // Runtime dispatch owns the correlation: every Node's discriminant is the + // keyed-slot entry passed alongside that same Node. TypeScript does not + // distribute an object containing a union into a union of objects itself. + const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner + return ( +
+ {renderSlot('conversation.chat.node', routedOwner, { + entryKey: routedNode.kind, + hookContext: nodeKey, + fallback: ( + t('json.truncated', { total })} + /> + ), + })} +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index c600b3b0aa..d16608c856 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,6 +1,5 @@ -/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool - runs) via the column gap and between consecutive tool rows via the group - gap. Input padding cap rides the skeleton. Under +/* Chat flow: one 16px rhythm everywhere through the column gap. Input + padding cap rides the skeleton. Under `[data-conversation-scroll]` the column host owns overflow and this view is ordinary flow (see ConversationRoot active-phase rules). */ @@ -51,10 +50,11 @@ min-width: 0; } -.toolGroup { - display: flex; - flex-direction: column; - gap: 16px; +/* A keyed renderer may intentionally decline its row after dispatch (the + completed-turn tail does this when it owns neither actions nor extensions). + An empty flex item must not consume the column gap. */ +.flowItem:empty { + display: none; } .callRow { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 3636a2a971..44ae6f345a 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,41 +1,24 @@ -// ChatView: the default conversation view — message flow with user bubbles, -// assistant narration, tool summary rows grouped into step runs, pending -// cards, paging, and bottom-follow. Session stats live on -// 'conversation.composer.dock' (sticky with the composer). Pure component -// registered directly; its registration declares the whole-Tool -// 'conversation.chat.tool' seat. ui-tool owns root/subcall composition and -// keyed per-tool dispatch behind that boundary. +// ChatView: the default conversation view — one stable keyed parent list over +// final business Nodes, plus paging, pending steering and bottom-follow. +// Each row dispatches through 'conversation.chat.node'; ui-tool owns the +// tool-call renderer and its recursive root/subcall composition. // // Scroll: when nested under `[data-conversation-scroll]` (active conversation // column), that host is the scrollport and this view is flow content; when // mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and // prepend anchoring always target the resolved scrollport. // -// Render economics (architecture RFC performance model): the list parent -// subscribes to snapshot segments that do NOT change per streaming chunk -// (nodes/runningCalls/pending keep their references across chunk batches), so -// during a token storm only StreamingTail re-renders; history rows hold via -// memo on cache-stable node slices. Selection changes re-render the parent -// map but only rows whose own selected bit flipped. renderSlot is -// entry-identity-stable (framework binding cache), so passing it through -// memoized rows never churns them. +// Render economics: order changes only when rows enter, leave or move. Each +// ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool +// lifecycle updates replace only their own row without remounting it. -import { - memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, -} from 'react' -import type { - CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' -import { AssistantMarkdown } from './AssistantMarkdown.tsx' -import { CompactionCommandCard } from './CompactionCommandCard.tsx' -import { GenericCommandCard } from './GenericCommandCard.tsx' -import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' +import { PendingSteeringBubble } from './MessageItem.tsx' +import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' -import { deriveTurnMetrics } from './turn-metrics.ts' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 @@ -98,35 +81,8 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | return visibleRows[0] ?? rows[0] ?? null } -type OpenFile = (path: string) => void - -type InspectCall = (callId: string) => void - -/** Declared child-slot render share (stable framework binding). */ -type RenderChatSlot = ChatViewSlotProps['renderSlot'] - type ChatScrollPosition = NonNullable> -/** ui-slots' UseSession is deliberately wide (dependency direction); the - * chat view narrows once to the runtime snapshot the binding actually feeds. */ -type UseConversation = SnapshotSelectorHook - -function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean { - return callId !== undefined - && (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId))) -} - -function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null { - if (!running) return null - for (let index = nodes.length - 1; index >= 0; index -= 1) { - const node = nodes[index] - if (node === undefined) continue - if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq - if (node.kind === 'assistant' || node.kind === 'user') return null - } - return null -} - /** Capture a reflow-resistant reader position from the current rendered window. */ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null { const row = pagingAnchor(list, scrollport) @@ -139,77 +95,13 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP } } -/** One ordered root Tool call handed intact to the Tool presentation plugin. */ -const ToolSeat = memo(function ToolSeat({ - renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall, -}: { - renderSlot: RenderChatSlot - callId: string - toolName: string - block: ToolResultNode | RunningToolCall - openFile: OpenFile - selectedCallId?: string | undefined - cwd: string | undefined - inspectCall: InspectCall -}) { - const owner = useMemo(() => ({ - callId, toolName, block, selectedCallId, cwd, openFile, inspectCall, - }), [callId, toolName, block, selectedCallId, cwd, openFile, inspectCall]) - return renderSlot('conversation.chat.tool', owner) -}) - -/** Consecutive tool results as one step-run group (uniform 16px rhythm). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, cwd, inspectCall }: { - renderSlot: RenderChatSlot - results: readonly ToolResultNode[] - openFile: OpenFile - /** Tool ownership resolves whether the selection is this root or one of its children. */ - selectedCallId: string | undefined - /** Session workspace root for path-relative summaries. */ - cwd: string | undefined - inspectCall: InspectCall -}) { - return ( -
- {results.map(node => ( - - ))} -
- ) -}) - -/** One command lifecycle row: keyed dispatch on the command name with the - * generic card as the render-site fallback (zero registration required). A - * run-less cross-window node has no name and always lands on the fallback. */ -const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: { - renderSlot: RenderChatSlot - node: CommandNode - compaction?: Extract - t: ChatViewSlotProps['t'] -}) { - const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node]) - const fallback = node.name === 'compact' - ? - : - return ( -
- {renderSlot('conversation.chat.commandview', owner, { - entryKey: node.name ?? '', - fallback, - })} -
- ) -}) +function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { + let latest: number | null = null + for (const turn of timeline.turns.values()) { + if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time + } + return latest +} /** Turn-level model activity label retained across first-token, tool, and streaming phases. */ function TurnStatus({ startTime, t }: { @@ -247,52 +139,32 @@ function TurnStatus({ startTime, t }: { ) } -/** The streaming partial, isolated so chunk batches re-render only this tail; - * the column ResizeObserver owns bottom-follow when its box grows. */ -function StreamingTail({ useSession, t }: { - useSession: UseConversation - t: ChatViewSlotProps['t'] -}) { - const partial = useSession(s => s.partial) - if (partial === null) return null - return -} - /** * The chat view slot entry: pure component over the composed props; each - * ordered root Tool call crosses the declared whole-Tool render seat. + * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, + useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, fileMentions, t, }: ChatViewSlotProps) { - const nodes = useSession(s => s.nodes) - const turnTimings = useSession(s => s.turnTimings) - const turnEnds = useSession(s => s.turnEnds) + const order = useSession(s => s.chat.order) + const nodeStore = useSession(s => s.chat.nodes) + const timeline = useSession(s => s.chat.timeline) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) - const runningCalls = useSession(s => s.runningCalls) const openState = useSession(s => s.openState) const openError = useSession(s => s.openError) const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) - const items = useMemo(() => deriveChatFlow(nodes), [nodes]) const pendingSteering = useMemo( () => inbox.filter(item => item.placement === 'steering'), [inbox], ) - const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) - // Only the last content assistant of each completed turn owns IconActions; - // mid-turn text and every node of a running turn omit `time`, so - // AssistantMarkdown stays chrome-free until the answer settles. - const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds]) - const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) - const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) - const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) + const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) const listRef = useRef(null) const columnRef = useRef(null) @@ -312,11 +184,12 @@ export function ChatView({ * scrolls the rest of the way to the floor). */ const followSigRef = useRef(null) - const firstSeq = nodes[0]?.seq ?? null - const lastItem = items[items.length - 1] - const lastKey = lastItem?.key ?? null + const firstKey = order[0] + const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null + const lastKey = order.at(-1) ?? null + const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey) const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` + const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}` const toBottom = (el: HTMLElement): void => { anchorRef.current = null @@ -377,8 +250,7 @@ export function ChatView({ firstSeqRef.current = firstSeq // Own words must be visible: a new trailing user node force-scrolls // (send lives in the composer, so arrival is detected here, not armed there). - const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' + const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey @@ -490,71 +362,6 @@ export function ChatView({ loadOlder() } - const renderItem = (item: ChatFlowItem): ReactNode => { - if (item.kind === 'tool-group') { - return ( - - ) - } - if (item.kind === 'command-compaction') { - return ( - - ) - } - const node: ConversationNode = item.node - if (node.kind === 'assistant') { - const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined - // Metrics gate on the settled in-window timing: turn/start loaded means - // every step of the turn is loaded, so first-step TTFT is genuine. - const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn) - return ( - - ) - } - if (node.kind === 'command') { - return - } - /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ - if (node.kind === 'tool-result') return null - return ( - - ) - } - return (
@@ -572,43 +379,21 @@ export function ChatView({
)} - {items.map(item => ( -
- {renderItem(item)} -
+ {order.map(nodeKey => ( + ))} - - {runningCalls.length > 0 && ( -
- {runningCalls.map(call => ( - - ))} -
- )} {/* No pending placeholders: questions (ui-question) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx b/packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx new file mode 100644 index 0000000000..a1fc9f197a --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx @@ -0,0 +1,40 @@ +import { memo, useMemo } from 'react' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { + ChatNodeViewProps, CommandRowOwnerProps, +} from '../contract/slots.ts' +import { CompactionCommandCard } from './CompactionCommandCard.tsx' +import { GenericCommandCard } from './GenericCommandCard.tsx' +import css from './ChatView.module.css' + +type CommandNodeViewProps = ChatNodeViewProps<'command'> & PropsRenderSlots<'conversation.chat.commandview'> + +/** Ordinary command lifecycle renderer with command-name keyed specialization. */ +export const CommandNodeView = memo(function CommandNodeView({ node, renderSlot, t }: CommandNodeViewProps) { + const command = node.data + const owner = useMemo(() => ({ node: command }), [command]) + return ( +
+ {renderSlot('conversation.chat.commandview', owner, { + entryKey: command.name ?? '', + fallback: , + })} +
+ ) +}) + +/** One integrated `/compact` command and compaction transaction renderer. */ +export const ManualCompactionNodeView = memo(function ManualCompactionNodeView({ + node, t, +}: ChatNodeViewProps<'manual-compaction'>) { + const data = node.data + return ( +
+ +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 30b51b3870..972fba8710 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -7,30 +7,15 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, - TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + ModelRetryNode, TurnErrorNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' -export interface MessageItemProps { - node: - | UserMessageNode - | SteeringMessageNode - | ContextMessageNode - | CompactionSummaryNode - | ModelRetryNode - | TurnErrorNode - | UnknownSurfaceNode - retryActive?: boolean - /** The owning view's locale seat, passed down as a plain prop. */ - t: ChatViewSlotProps['t'] -} - function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { const texts: string[] = [] const rest: unknown[] = [] @@ -219,50 +204,69 @@ export function PendingSteeringBubble({ content, t }: { ) } -export const MessageItem = memo(function MessageItem({ - node, retryActive = false, t, -}: MessageItemProps) { - const truncated = (total: number): string => t('json.truncated', { total }) - switch (node.kind) { - case 'user': - case 'steering': - return ( - ( - - )} - /> - ) - case 'context': - return ( - ) { + const data = node.data + return ( + ( + - ) - case 'compaction': - return - case 'model-retry': - return - case 'turn-error': - return - default: - return ( -
- -
- ) - } + )} + /> + ) +}) + +/** Injected-context keyed Chat renderer. */ +export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) { + const data = node.data + return ( + + ) +}) + +/** Automatic compaction keyed Chat renderer. */ +export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) { + return +}) + +/** Correlated retry-chain keyed Chat renderer. */ +export const RetryNodeView = memo(function RetryNodeView({ node, t }: ChatNodeViewProps<'model-retry'>) { + const data = node.data + return +}) + +/** Terminal turn-error keyed Chat renderer. */ +export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: ChatNodeViewProps<'turn-error'>) { + return +}) + +/** Explicit unknown-surface keyed Chat renderer. */ +export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) { + const data = node.data + return ( +
+ t('json.truncated', { total })} + /> +
+ ) }) diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 8e672740cf..8e43f3c0b4 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -157,9 +157,9 @@ export interface StatsLineProps { } export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { - const nodes = useSession(s => s.nodes) + const settledNodes = useSession(s => s.chat.legacy.nodes) + const stats = useMemo(() => deriveStats(settledNodes), [settledNodes]) const usage = useProjection('tokenUsage') - const stats = useMemo(() => deriveStats(nodes), [nodes]) // Pipe-separated groups (figma stats strip); a group with no data drops out whole. const groups: string[] = [] if (stats.steps > 0) { diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css new file mode 100644 index 0000000000..831e6e212b --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css @@ -0,0 +1,9 @@ +.root { + display: flex; + flex-direction: column; + gap: 16px; +} + +.actions { + margin-left: -6px; +} diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx new file mode 100644 index 0000000000..bf48fc75c6 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx @@ -0,0 +1,43 @@ +import { memo } from 'react' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' +import { MessageIconActions } from './MessageIconActions.tsx' +import { assistantText } from './turn-assistant.ts' +import css from './TurnTailNodeView.module.css' + +type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'> + +/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ +export const TurnTailNodeView = memo(function TurnTailNodeView({ + node, openFile, forkAt, renderSlotChain, t, +}: TurnTailNodeViewProps) { + const data = node.data + const turn = node.location.kind === 'turn' || node.location.kind === 'step' + ? node.location.turn + : undefined + if (turn === undefined) return null + const closing = data.closing + const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile } + const tail = renderSlotChain('conversation.chat.turnTail', owner) + if (closing === null) return tail === null ? null :
{tail}
+ const runMs = turn.start === undefined || turn.end === undefined + ? undefined + : Math.max(0, turn.end.time - turn.start.time) + return ( +
+ {tail} + { forkAt(closing.finalNode.seq) }} + branchUnavailable={data.branchUnavailable} + className={css.actions} + t={t} + /> +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts deleted file mode 100644 index a9b12cbe2d..0000000000 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Chat flow derivation: ConversationSnapshot nodes -> render items. Tool - * results group into consecutive-run tool groups (figma step-summary flow, - * VERTICAL gap10) alternating with narration. Consecutive retry notices - * reuse the first notice's row while projecting the latest retry turn. - * Item identity keys are stable across snapshots so the list parent can - * subscribe to keys only while rows subscribe to content. IconActions ownership - * and completed-turn branch points are derived here too so ChatView and the - * flow share their gates. - */ -import type { - AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' - -/** One renderable flow item; key is the React key and the parent's identity unit. */ -export type ChatFlowItem = - | { kind: 'node'; key: string; node: ConversationNode } - | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } - | { - kind: 'command-compaction' - key: string - command: CommandNode - compaction: CompactionSummaryNode - } - -/** Match explicit command outcome references to exactly one compaction checkpoint. */ -function commandCompactionPairs(nodes: readonly ConversationNode[]): { - readonly byCommandId: ReadonlyMap - readonly byCompactionSeq: ReadonlyMap -} { - const commandsBySource = new Map() - for (const node of nodes) { - if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue - const source = node.outcome.sourceEventSeq - if (source === undefined) continue - commandsBySource.set(source, commandsBySource.has(source) ? null : node) - } - const compactionsBySummary = new Map() - for (const node of nodes) { - if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue - const summary = node.summaryEventSeq - compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node) - } - const byCommandId = new Map() - const byCompactionSeq = new Map() - for (const [source, command] of commandsBySource) { - const compaction = compactionsBySummary.get(source) - if (command === null || compaction === undefined || compaction === null) continue - byCommandId.set(command.commandId, compaction) - byCompactionSeq.set(compaction.seq, command) - } - return { byCommandId, byCompactionSeq } -} - -/** - * True when the node has model-visible text content worth IconActions chrome. - * Shared with {@link AssistantMarkdown}'s mount gate so ownership and mounting - * cannot diverge. - * @param blocks - assistant blocks of one finalized node. - * @returns Whether any text block carries non-blank content. - */ -export function hasContentText(blocks: readonly AssistantBlock[]): boolean { - return blocks.some(block => block.kind === 'text' && block.text.trim() !== '') -} - -/** An assistant node that renders nothing: only tool-call heads (rows render - * via the grouping pass) and blank text/reasoning. Skipped by the flow so it - * neither costs column gaps nor splits a tool-row run. Interrupted nodes - * always render (the 已停止 marker). */ -function rendersNothing(node: ConversationNode): boolean { - return node.kind === 'assistant' && node.interrupted !== true - && node.blocks.every(b => b.kind === 'tool-call' - || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) -} - -/** - * Seq set of assistants that own IconActions: the last content-text assistant - * of each *completed* turn. A turn without a `turn/end` in the window is still - * producing steps, so its latest narration is not the settled answer and owns - * nothing; mid-turn narration of a completed turn stays chrome-free too. - * @param nodes - snapshot nodes (surface order). - * @param turnEnds - completed turn boundaries retained from the event window. - * @returns Seq values ChatView may pass as `time` into AssistantMarkdown. - */ -export function assistantActionsSeqs( - nodes: readonly ConversationNode[], - turnEnds: ReadonlyMap, -): ReadonlySet { - const lastByTurn = new Map() - for (const node of nodes) { - if (node.kind !== 'assistant' || !turnEnds.has(node.turn) || !hasContentText(node.blocks)) continue - lastByTurn.set(node.turn, node.seq) - } - return new Set(lastByTurn.values()) -} - -/** - * Exact start time of the latest in-window turn without a matching end time. - * @param turnTimings - In-window turn timings in event order. - * @returns Unix epoch ms, or null when the running turn started outside the window. - */ -export function runningTurnStartTime( - turnTimings: ConversationSnapshot['turnTimings'], -): number | null { - let latest: number | null = null - for (const timing of turnTimings.values()) { - if (timing.endTime === undefined) latest = timing.startTime - } - return latest -} - -/** - * Seq set of assistant answers that may fork: the completed turn's transcript - * tail, when that tail is the turn's own content-text assistant. A later tool, - * reasoning, error, or other transcript node leaves the answer's branch action - * unavailable because the Host would include the whole turn. User and steering - * bubbles carry no branch action at all: a fork at their seq cuts at the same - * `turn/end` as the answer's, so the affordance lives only under the settled - * answer. - * @param nodes - snapshot nodes in event order. - * @param turnEnds - completed turn boundaries retained from the event window. - * @returns Assistant seq values whose visible position matches the fork boundary. - */ -export function assistantBranchSeqs( - nodes: readonly ConversationNode[], - turnEnds: ReadonlyMap, -): ReadonlySet { - const result = new Set() - const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1]) - let nodeIndex = 0 - for (const [turn, endSeq] of boundaries) { - let tail: ConversationNode | undefined - while (nodeIndex < nodes.length) { - const candidate = nodes[nodeIndex] - if (candidate === undefined || candidate.seq > endSeq) break - tail = candidate - nodeIndex++ - } - if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) { - result.add(tail.seq) - } - } - return result -} - -/** - * Group finalized nodes into the step-summary flow. - * @param nodes - snapshot nodes in human-transcript and durable-notice order. - * @returns flow items; consecutive tool results group and retry notices reuse their first key. - */ -export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { - const items: ChatFlowItem[] = [] - const pairs = commandCompactionPairs(nodes) - let group: ToolResultNode[] | null = null - for (const node of nodes) { - if (rendersNothing(node)) continue - if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) { - continue - } - if (node.kind === 'compaction') { - group = null - const command = pairs.byCompactionSeq.get(node.seq) - if (command !== undefined) { - items.push({ - kind: 'command-compaction', - key: `c${command.commandId}`, - command, - compaction: node, - }) - } else { - items.push({ kind: 'node', key: `n${node.seq}`, node }) - } - continue - } - if (node.kind === 'tool-result') { - if (group === null) { - group = [node] - items.push({ kind: 'tool-group', key: `g${node.seq}`, results: group }) - } else { - group.push(node) - } - } else if (node.kind === 'model-retry') { - group = null - const previous = items[items.length - 1] - if ( - previous?.kind === 'node' - && previous.node.kind === 'model-retry' - ) { - items[items.length - 1] = { ...previous, node } - } else { - items.push({ kind: 'node', key: `n${node.seq}`, node }) - } - } else { - group = null - items.push({ - kind: 'node', - key: node.kind === 'command' && node.name === 'compact' - ? `c${node.commandId}` - : `n${node.seq}`, - node, - }) - } - } - return items -} - -/** - * Key projection for the list parent's selector (content-blind identity). - * @param items - derived flow items. - * @returns joined key string usable with Object.is short-circuiting. - */ -export function flowKeys(items: readonly ChatFlowItem[]): string { - return items.map(i => i.key).join('|') -} diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts new file mode 100644 index 0000000000..8926fc2a8e --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts @@ -0,0 +1,46 @@ +import type { Context } from 'cordis' +import { NS } from '../locales.ts' +import { AssistantNodeView } from './AssistantNodeView.tsx' +import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' +import { + CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, + UnknownNodeView, UserMessageNodeView, +} from './MessageItem.tsx' +import { TurnTailNodeView } from './TurnTailNodeView.tsx' + +/** + * Register this package's business renderers behind the keyed Chat Node seat. + * @param ctx - owning UI Conversation context. + */ +export function registerChatNodeRenderers(ctx: Context): void { + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'command', + locale: NS, + children: { 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' } }, + }, CommandNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'manual-compaction', locale: NS }, ManualCompactionNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'compaction', locale: NS }, CompactionNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'turn-tail', + locale: NS, + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + }, TurnTailNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView)) +} diff --git a/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts b/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts new file mode 100644 index 0000000000..dca9992b8d --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts @@ -0,0 +1,46 @@ +import type { + ConversationSnapshot, ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' +import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNode } from '../contract/chat-nodes.ts' + +function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { + return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined +} + +/** + * Read one root Tool lifecycle through the internal Chat Node index. + * @param snapshot - current Conversation snapshot. + * @param rootCallId - root call identity and Tool Context identity. + * @returns root lifecycle when it is materialized in the current window. + */ +export function rootToolCall( + snapshot: ConversationSnapshot, + rootCallId: string, +): ToolCallBlock | undefined { + return toolNode(snapshot.chat.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root +} + +/** + * Find any root or nested Tool lifecycle through the internal Node store. + * @param snapshot - current Conversation snapshot. + * @param callId - root or nested call identity. + * @returns current Tool lifecycle when materialized in the loaded window. + */ +export function findToolCall(snapshot: ConversationSnapshot, callId: string): ToolCallBlock | undefined { + const visit = (block: ToolCallBlock): ToolCallBlock | undefined => { + if (block.callId === callId) return block + for (const child of block.subCalls) { + const found = visit(child) + if (found !== undefined) return found + } + return undefined + } + for (const node of snapshot.chat.nodes.values()) { + const root = toolNode(node)?.data.root + if (root === undefined) continue + const found = visit(root) + if (found !== undefined) return found + } + return undefined +} diff --git a/packages/client/ui-conversation/src/client/chat/turn-assistant.ts b/packages/client/ui-conversation/src/client/chat/turn-assistant.ts new file mode 100644 index 0000000000..2abfff56b3 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/turn-assistant.ts @@ -0,0 +1,10 @@ +import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' + +/** + * Collect visible prose from one Assistant lifecycle. + * @param blocks - Assistant content blocks. + * @returns concatenated text blocks. + */ +export function assistantText(blocks: readonly AssistantBlock[]): string { + return blocks.flatMap(block => block.kind === 'text' ? [block.text] : []).join('') +} diff --git a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts index b7cc5ddb72..b93bbfc8eb 100644 --- a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts +++ b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts @@ -1,6 +1,6 @@ // Latency/throughput folds shared by the settled turn footer and StatsLine. -import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantMessageNode, ConversationNode } from '@deepseek-ai/dsh-client-runtime/client' /** Latency and decode-throughput readings for one turn's footer. */ export interface TurnMetrics { @@ -24,7 +24,7 @@ interface UsageLike { outputTokens?: number } -type AssistantNode = Extract +type AssistantNode = AssistantMessageNode function usageOutputTokens(usage: unknown): number | null { if (typeof usage !== 'object' || usage === null) return null @@ -67,7 +67,7 @@ interface TurnFold { * @param nodes - Snapshot nodes of the loaded window. * @returns Turn number → available metrics; turns with none are absent. */ -export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map { +export function deriveTurnMetrics(nodes: readonly ConversationNode[]): Map { const folds = new Map() for (const node of nodes) { if (node.kind !== 'assistant') continue diff --git a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts new file mode 100644 index 0000000000..3415c502a5 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts @@ -0,0 +1,82 @@ +import type { + AssistantBlock, AssistantMessageNode, ChatConversationViewNode, CommandNode, + CompactionSummaryNode, ModelRetryNode, RunningToolCall, ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' + +/** Merge-extensible payload registry keyed by final Chat renderer kind. */ +export interface ChatNodeDataMap {} + +/** Renderer kinds contributed by the currently installed Chat business modules. */ +export type ChatNodeKind = keyof ChatNodeDataMap & string + +/** Final Chat Node narrowed to one registered renderer kind and payload. */ +export type ChatNode = { + [RegisteredKind in Kind]: ChatConversationViewNode & { + readonly kind: RegisteredKind + readonly data: ChatNodeDataMap[RegisteredKind] + } +}[Kind] + +/** Final Assistant row payload shared by streaming and settled states. */ +export interface AssistantChatData { + readonly status: 'running' | 'settled' | 'interrupted' + readonly turn: number + readonly step: number + readonly blocks: readonly AssistantBlock[] + readonly time: number + readonly usage?: unknown + readonly finalNode?: AssistantMessageNode +} + +/** Settled or interrupted Assistant payload with its durable presentation node. */ +export type FinalAssistantChatData = AssistantChatData & { + readonly finalNode: AssistantMessageNode +} + +/** Root Tool row payload; the root lifecycle owns all recursive subcalls. */ +export interface ToolChatData { + readonly root: ToolCallBlock +} + +/** One manual command and its correlated compaction transaction. */ +export interface ManualCompactionChatData { + readonly command: CommandNode + readonly compaction: CompactionSummaryNode | null +} + +/** One durable retry chain rendered as a single row. */ +export interface RetryChatData { + readonly attempts: readonly ModelRetryNode[] + readonly current: ModelRetryNode +} + +/** Turn-local footer row that owns actions and optional feature contributions. */ +export interface TurnTailChatData { + readonly turn: number + readonly seq: number + readonly time: number + /** Last finalized content-bearing Assistant in this Turn. */ + readonly closing: FinalAssistantChatData | null + /** Whether later Assistant/Step material makes the closing seq non-tail. */ + readonly branchUnavailable: boolean + readonly ttftMs?: number + readonly tokensPerSecond?: number +} + +/** + * Test whether a Tool root has settled. + * @param block - Tool root lifecycle value. + * @returns whether the root carries its final result. + */ +export function isSettledTool(block: ToolCallBlock): block is Extract { + return 'kind' in block +} + +/** + * Test whether a Tool root is still running. + * @param block - Tool root lifecycle value. + * @returns whether the root lacks a final result. + */ +export function isRunningTool(block: ToolCallBlock): block is RunningToolCall { + return !isSettledTool(block) +} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a57bcbd5a7..474e7f9db5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,15 +1,21 @@ /** Conversation slot declarations and their composed component props. */ import type { ReactNode, RefObject } from 'react' import type { - InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, + InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, + SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + CommandNode, CompactionSummaryNode, ConversationSnapshot, ConversationTurnDataMap, + ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, + TurnLocation, WorkspaceId, +} from '@deepseek-ai/dsh-client-runtime/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts' +import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -31,13 +37,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * conversation snapshot through the standard kit. */ 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } - /** - * One root Tool call at its ordered ChatFlow position. The chat view owns - * placement; ui-tool owns root/subcall composition and keyed dispatch. - * The filler preserves the call-anchor DOM contract documented by - * {@link ToolTreeOwnerProps} for every root and child wrapper. - */ - 'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps } + /** Final business node renderer, dispatched by `ChatConversationViewNode.kind`. */ + 'conversation.chat.node': { + kind: 'keyed' + scope: 'session' + owner: ChatNodeOwnerProps + keyProps: { [Kind in ChatNodeKind]: { node: ChatNode } } + hookContext: string + inject: ChatNodeTurnDataInjected + } /** * The chat view's per-command row hole: keyed dispatch on the command * name (`command/run.name`; a run-less cross-window node has none and @@ -171,7 +179,7 @@ export interface ConvViewOwnerProps { export interface ChatFileMentions { /** * Mention vocabulary for the closing message the owner currency names. - * @param owner - Turn-tail owner currency (nodes, closing seq, opener). + * @param owner - Turn-tail owner currency (Turn data, closing seq, opener). * @returns The resolver MarkdownText consumes, or undefined when the turn * produced nothing worth linking. */ @@ -186,14 +194,13 @@ declare module 'cordis' { } /** - * Owner currency of the chat view's turn-tail hole: the finalized snapshot - * and the closing assistant's anchor. Registrants derive their own facts - * from the nodes (the owner never pre-chews a feature's vocabulary), and - * open files through the same opener the tool rows use. + * Owner currency of the chat view's turn-tail hole: the engine-owned Turn and + * the closing assistant's anchor. Registrants read their own typed Turn data + * and open files through the same opener the tool rows use. */ export interface TurnTailOwnerProps { - /** Finalized snapshot nodes in surface order. */ - nodes: readonly ConversationNode[] + /** Engine-owned closing Turn boundary. */ + turn: TurnLocation /** The closing assistant's seq — the anchor the tail renders under. */ seq: number /** @@ -203,34 +210,34 @@ export interface TurnTailOwnerProps { openFile: (path: string) => void } -/** - * Owner currency of the chat view's whole-Tool rendering seat. The filler - * wraps every rendered root and child with `data-chat-anchor-key="call:"` - * and `data-chat-call-id=""`, plus `data-selected="true"` for the selected - * call. ChatView consumes those anchors to restore prepend/paging position. - */ -export interface ToolTreeOwnerProps { - /** Root Tool call identity, stable across running → settled. */ - callId: CallId - /** Root wire Tool name. */ - toolName: string - /** Frozen root call slice: running call or settled result node. */ - block: ToolCallBlock - /** Selected call id; the Tool owner resolves whether it is root or child. */ - selectedCallId?: CallId | undefined - /** Session workspace root; path summaries display relative to it. */ - cwd?: string | undefined - /** - * Open a tool-arg filesystem path with the host OS default application. - * The conversation owner resolves relative paths against the session cwd. - */ - openFile: (path: string) => void - /** - * Jump to any call in this tree in the trajectory view. - */ - inspectCall: (callId: CallId) => void +/** Hook constrained to business data published on the current Chat Node's Turn. */ +export type UseChatNodeTurnData = ( + key: Key, +) => Readonly | undefined + +/** Slot-level Hook factory used by renderers reading their Node's Turn data. */ +export interface ChatNodeTurnDataInjected { + hooks: { + turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> + } } +/** Stable owner currency delivered to one keyed Chat business renderer. */ +export interface ChatNodeOwnerProps { + /** Selected Tool call, when the shared details store names one. */ + selectedCallId?: CallId | undefined + /** Session workspace root; Tool summaries display paths relative to it. */ + cwd?: string | undefined + openFile: (path: string) => void + inspectCall: (callId: CallId) => void + forkAt: (seq: number) => void + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined +} + +/** Full props of one registered keyed Chat business renderer. */ +export type ChatNodeViewProps = + PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'conversation'> + /** Owner currency of the details panel's Tool output renderer. */ export interface DetailsToolOwnerProps { /** Frozen selected call slice. */ @@ -555,7 +562,7 @@ export interface ChatViewInjected { /** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */ export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'> + PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node'> & PropsStore & ChatViewInjected & PropsLocale<'conversation'> /** diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts new file mode 100644 index 0000000000..2bdf960226 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -0,0 +1,316 @@ +import type { Context } from 'cordis' +import type { + AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, + ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Streaming, settled, or interrupted Assistant step. */ + 'assistant-step': AssistantChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationStepDataMap { + /** Streaming, settled, or interrupted Assistant material for this Step. */ + 'assistant-step': AssistantChatData + } +} + +interface AssistantState { + readonly turn: number + readonly step: number + readonly blocks: readonly (AssistantBlock | undefined)[] + readonly firstVisibleSeq: number | undefined + readonly firstVisibleTime: number | undefined + readonly firstTokenTime: number | undefined + readonly hidden: boolean + readonly final: ConversationMatch | undefined + readonly usage: unknown +} + +function initialState(turn: number, step: number): AssistantState { + return { + turn, + step, + blocks: [], + firstVisibleSeq: undefined, + firstVisibleTime: undefined, + firstTokenTime: undefined, + hidden: false, + final: undefined, + usage: undefined, + } +} + +function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] { + return blocks.filter((block): block is AssistantBlock => block !== undefined) +} + +function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'tool-call') return false + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +function resetForRetry(state: AssistantState): AssistantState { + return { + ...initialState(state.turn, state.step), + firstTokenTime: state.firstTokenTime, + hidden: true, + } +} + +function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState { + if (match.event.type !== 'assistant/chunk') return state + const chunk = match.event.data.chunk + const blocks = [...state.blocks] + switch (chunk.type) { + case 'block-start': + blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) + break + case 'text-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text } + break + } + case 'reasoning-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { kind: 'reasoning', text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text } + break + } + case 'tool-call-delta': { + const previous = blocks[chunk.index] + const base = previous?.kind === 'tool-call' + ? previous + : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' } + blocks[chunk.index] = { + kind: 'tool-call', + callId: base.callId || String(chunk.id), + name: chunk.name ?? base.name, + argsRaw: base.argsRaw + chunk.argumentsDelta, + } + break + } + case 'block-end': + blocks[chunk.index] = toAssistantBlock(chunk.block) + break + case 'usage': + return { ...state, usage: chunk.usage } + default: + return state + } + const visible = hasVisibleContent(compactBlocks(blocks)) + const firstToken = isTokenDelta(chunk) + return { + ...state, + blocks, + hidden: visible ? false : state.hidden, + ...visible && state.firstVisibleSeq === undefined + ? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time } + : {}, + ...firstToken && state.firstTokenTime === undefined + ? { firstTokenTime: match.event.time } + : {}, + } +} + +function closedBoundary(location: ConversationLocation): { seq: number; time: number } | undefined { + if (location.kind === 'step' && location.step.status === 'closed' && location.step.end !== undefined) { + return location.step.end + } + if ((location.kind === 'step' || location.kind === 'turn') + && location.turn.status === 'closed' && location.turn.end !== undefined) { + return location.turn.end + } + return undefined +} + +function finalNode( + state: AssistantState, + context: ConversationNodeContext, +): AssistantMessageNode | undefined { + const final = state.final + if (final?.event.type === 'assistant/message') { + const event = final.event + return { + kind: 'assistant', + seq: event.seq, + time: event.time, + turn: state.turn, + step: state.step, + blocks: toAssistantBlocks(event.data.message.content), + usage: event.data.usage, + timing: { + stepStartTime: context.start?.event.time ?? null, + firstTokenTime: state.firstTokenTime ?? null, + completedTime: event.time, + }, + } + } + const location = context.start?.location ?? context.matches.at(-1)?.location + const boundary = location === undefined ? undefined : closedBoundary(location) + const blocks = compactBlocks(state.blocks) + if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined + return { + kind: 'assistant', + seq: boundary.seq - 0.9, + time: boundary.time, + turn: state.turn, + step: state.step, + blocks, + interrupted: true, + } +} + +function fallbackState(context: ConversationNodeContext): AssistantState | undefined { + let state: AssistantState | undefined + for (const match of context.matches) { + if (match.event.type === 'assistant/chunk') { + state ??= initialState(match.event.data.turn, match.event.data.step) + state = updateChunk(state, match) + continue + } + if (match.event.type === 'assistant/message') { + state ??= initialState(match.event.data.turn, match.event.data.step) + state = { + ...state, + blocks: toAssistantBlocks(match.event.data.message.content), + hidden: false, + final: match, + usage: match.event.data.usage, + } + continue + } + if ((match.event.type as string) === 'llm/retry' && state !== undefined) { + state = resetForRetry(state) + } + } + return state +} + +interface AssistantProjection { + readonly data: AssistantChatData + readonly anchorSeq: number + readonly visible: boolean + readonly settled: AssistantMessageNode | undefined +} + +function projectAssistant(context: ConversationNodeContext): AssistantProjection | undefined { + const state = context.state ?? fallbackState(context) + if (state === undefined) return undefined + const settled = finalNode(state, context) + const blocks = settled?.blocks ?? compactBlocks(state.blocks) + const visible = hasVisibleContent(blocks) + const status = settled?.interrupted === true + ? 'interrupted' + : settled === undefined ? 'running' : 'settled' + const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0 + const time = settled?.time ?? state.firstVisibleTime ?? context.matches[0]?.event.time ?? 0 + return { + anchorSeq, + visible, + settled, + data: { + status, + turn: state.turn, + step: state.step, + blocks, + time, + ...state.usage === undefined ? {} : { usage: state.usage }, + ...settled === undefined ? {} : { finalNode: settled }, + }, + } +} + +/** Per-step Assistant streaming/final/interruption Definition. */ +export const assistantDefinition: ConversationNodeDefinition = { + kind: 'assistant-step', + match: (event) => { + if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + if (event.type === 'assistant/chunk' + || (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) { + return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } + } + if ((event.type as string) === 'llm/retry') { + const data = event.data as unknown as { turn: number; step: number } + return { id: `${data.turn}:${data.step}`, role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'step/start') throw new Error('assistant-step start requires step/start') + return initialState(match.event.data.turn, match.event.data.step) + }, + update: (context, match) => { + if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match) + if (match.event.type === 'assistant/message') { + return { + ...context.state, + blocks: toAssistantBlocks(match.event.data.message.content), + hidden: false, + final: match, + usage: match.event.data.usage, + } + } + if ((match.event.type as string) === 'llm/retry') { + return resetForRetry(context.state) + } + return context.state + }, + publication: (match) => { + if (match.event.type === 'step/start') return 'none' + if (match.event.type !== 'assistant/chunk') return 'immediate' + const type = match.event.data.chunk.type + return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' + }, + buildLocationData: (context, scope) => { + if (scope !== 'step') return null + const projected = projectAssistant(context) + if (projected === undefined) return null + return { + kind: 'step', + turn: projected.data.turn, + step: projected.data.step, + key: 'assistant-step', + value: projected.data, + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const projected = projectAssistant(context) + if (projected === undefined) return null + if (projected.settled === undefined && !projected.visible) { + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const current = context.current.get('chat') + if (!state.hidden || current === undefined || current === null) return null + } + return chatNode(context, 'assistant-step', projected.anchorSeq, projected.data, { + visibility: projected.settled?.interrupted === true || projected.visible ? 'visible' : 'hidden', + }) + }, +} + +/** + * Register the Assistant lifecycle business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerAssistantConversationNode(ctx: Context): void { + ctx.conversationEvents.register(assistantDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts new file mode 100644 index 0000000000..f9491d2d16 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -0,0 +1,456 @@ +import type { Context } from 'cordis' +import type { + ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, + ConversationLocation, ConversationNode, ConversationTimelineSnapshot, + ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice, + PartialAssistant, RunningToolCall, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNode } from '../contract/chat-nodes.ts' +import { isRunningTool } from '../contract/chat-nodes.ts' + +const EMPTY_KEYS: readonly string[] = [] +const EMPTY_TURNS: readonly number[] = [] +const EMPTY_LIST: readonly never[] = [] + +function sameReferences(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +class MutableChatNodeStore implements ChatNodeStore { + private readonly byKey = new Map() + private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST + private valuesDirty = false + + get(key: string): ChatConversationViewNode | undefined { + return this.byKey.get(key) + } + + values(): readonly ChatConversationViewNode[] { + if (this.valuesDirty) { + this.valuesCache = [...this.byKey.values()] + this.valuesDirty = false + } + return this.valuesCache + } + + replace(nodes: readonly ChatConversationViewNode[]): void { + this.byKey.clear() + for (const node of nodes) this.byKey.set(node.key, node) + this.valuesCache = [...this.byKey.values()] + this.valuesDirty = false + } + + upsert(nodes: readonly ChatConversationViewNode[]): void { + let changed = false + for (const node of nodes) { + if (this.byKey.get(node.key) === node) continue + this.byKey.set(node.key, node) + changed = true + } + if (changed) this.valuesDirty = true + } +} + +class MutableChatLocationIndex implements ChatLocationNodeIndex { + private turns = new Map() + private steps = new Map() + + getTurn(turn: number): readonly string[] { + return this.turns.get(turn) ?? EMPTY_KEYS + } + + getStep(turn: number, step: number): readonly string[] { + return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS + } + + rebuild(order: readonly string[], store: ChatNodeStore): void { + const turns = new Map() + const steps = new Map() + for (const key of order) { + const location = store.get(key)?.location + if (location === undefined) continue + const coordinates = locationCoordinates(location) + if (coordinates.turn === undefined) continue + const turnKeys = turns.get(coordinates.turn) ?? [] + turnKeys.push(key) + turns.set(coordinates.turn, turnKeys) + if (coordinates.step === undefined) continue + const step = stepKey(coordinates.turn, coordinates.step) + const stepKeys = steps.get(step) ?? [] + stepKeys.push(key) + steps.set(step, stepKeys) + } + this.turns = updateIndex(this.turns, turns) + this.steps = updateIndex(this.steps, steps) + } + + /** Invalidate aggregate readers when member data changes without moving. */ + touch(nodes: readonly ChatConversationViewNode[]): void { + const turns = new Set() + const steps = new Set() + for (const node of nodes) { + const coordinates = locationCoordinates(node.location) + if (coordinates.turn === undefined || !this.turns.get(coordinates.turn)?.includes(node.key)) continue + turns.add(coordinates.turn) + if (coordinates.step !== undefined) steps.add(stepKey(coordinates.turn, coordinates.step)) + } + for (const turn of turns) { + const keys = this.turns.get(turn) + if (keys === undefined) continue + this.turns.set(turn, [...keys]) + } + for (const step of steps) { + const keys = this.steps.get(step) + if (keys === undefined) continue + this.steps.set(step, [...keys]) + } + } +} + +function updateIndex( + previous: ReadonlyMap, + nextMutable: ReadonlyMap, +): Map { + const next = new Map() + const keys = new Set([...previous.keys(), ...nextMutable.keys()]) + for (const key of keys) { + const before = previous.get(key) ?? EMPTY_KEYS + const candidate = nextMutable.get(key) ?? EMPTY_KEYS + const value = sameReferences(before, candidate) ? before : candidate + if (candidate.length > 0) next.set(key, value) + } + return next +} + +function stepKey(turn: number, step: number): string { + return `${turn}:${step}` +} + +function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { + if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } + if (location.kind === 'turn') return { turn: location.turn.turn } + return {} +} + +function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConversationViewNode[] { + return nodes + .filter(node => node.visibility === 'visible') + .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) +} + +interface LegacyContribution { + readonly anchorSeq: number + readonly nodes: readonly ConversationNode[] + readonly partial: PartialAssistant | null + readonly running: RunningToolCall | null +} + +const EMPTY_CONTRIBUTION: LegacyContribution = { + anchorSeq: 0, + nodes: EMPTY_LIST, + partial: null, + running: null, +} + +function legacyContribution(raw: ChatConversationViewNode): LegacyContribution { + const node = raw as ChatNode + if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION + switch (node.kind) { + case 'user': + case 'steering': + case 'context': + case 'command': + case 'compaction': + case 'turn-error': + case 'unknown': + return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null } + case 'assistant-step': { + const data = node.data + if (data.status === 'running') { + if (raw.visibility !== 'visible') return EMPTY_CONTRIBUTION + return { + anchorSeq: node.anchorSeq, + nodes: EMPTY_LIST, + partial: { turn: data.turn, step: data.step, blocks: data.blocks }, + running: null, + } + } + return { + anchorSeq: node.anchorSeq, + nodes: data.finalNode === undefined ? EMPTY_LIST : [data.finalNode], + partial: null, + running: null, + } + } + case 'tool-call': { + const root = node.data.root + return isRunningTool(root) + ? { anchorSeq: node.anchorSeq, nodes: EMPTY_LIST, partial: null, running: root } + : { anchorSeq: node.anchorSeq, nodes: [root], partial: null, running: null } + } + case 'manual-compaction': { + const data = node.data + return { + anchorSeq: node.anchorSeq, + nodes: data.compaction === null ? [data.command] : [data.command, data.compaction], + partial: null, + running: null, + } + } + case 'model-retry': + return { + anchorSeq: node.anchorSeq, + nodes: node.data.attempts, + partial: null, + running: null, + } + case 'turn-tail': + return EMPTY_CONTRIBUTION + default: + return EMPTY_CONTRIBUTION + } +} + +function sameContribution(left: LegacyContribution | undefined, right: LegacyContribution): boolean { + return left !== undefined + && left.anchorSeq === right.anchorSeq + && left.partial?.blocks === right.partial?.blocks + && left.partial?.turn === right.partial?.turn + && left.partial?.step === right.partial?.step + && left.running === right.running + && sameReferences(left.nodes, right.nodes) +} + +/** Incremental compatibility projection retained solely for unmigrated Trajectory consumers. */ +class LegacySliceBuilder { + private readonly contributions = new Map() + private readonly finalizedContributions = new Map() + private readonly runningContributions = new Map() + private readonly partialContributions = new Map() + private finalized: readonly ConversationNode[] = EMPTY_LIST + private runningCalls: readonly RunningToolCall[] = EMPTY_LIST + private partial: PartialAssistant | null = null + private timeline: ConversationTimelineSnapshot | undefined + private turnTimings: LegacyConversationSlice['turnTimings'] = new Map() + private turnEnds: LegacyConversationSlice['turnEnds'] = new Map() + + replace( + nodes: readonly ChatConversationViewNode[], + timeline: ConversationTimelineSnapshot, + ): LegacyConversationSlice { + this.contributions.clear() + this.finalizedContributions.clear() + this.runningContributions.clear() + this.partialContributions.clear() + for (const node of nodes) { + const contribution = legacyContribution(node) + this.contributions.set(node.key, contribution) + this.indexContribution(node.key, contribution) + } + this.rebuildFinalized() + this.rebuildRunning() + this.rebuildPartial() + this.updateTimeline(timeline) + return this.snapshot() + } + + apply( + upserts: readonly ChatConversationViewNode[], + timeline: ConversationTimelineSnapshot, + ): LegacyConversationSlice { + let finalizedChanged = false + let runningChanged = false + let partialChanged = false + for (const node of upserts) { + const contribution = legacyContribution(node) + const previous = this.contributions.get(node.key) + if (sameContribution(previous, contribution)) continue + finalizedChanged ||= finalizedContributionChanged(previous, contribution) + runningChanged ||= runningContributionChanged(previous, contribution) + partialChanged ||= partialContributionChanged(previous, contribution) + this.contributions.set(node.key, contribution) + this.indexContribution(node.key, contribution) + } + if (finalizedChanged) this.rebuildFinalized() + if (runningChanged) this.rebuildRunning() + if (partialChanged) this.rebuildPartial() + this.updateTimeline(timeline) + return this.snapshot() + } + + private indexContribution(key: string, contribution: LegacyContribution): void { + updateContributionIndex(this.finalizedContributions, key, contribution, contribution.nodes.length > 0) + updateContributionIndex(this.runningContributions, key, contribution, contribution.running !== null) + updateContributionIndex(this.partialContributions, key, contribution, contribution.partial !== null) + } + + private rebuildFinalized(): void { + const finalized = [...this.finalizedContributions.values()] + .flatMap(value => value.nodes) + .sort((left, right) => left.seq - right.seq) + if (!sameReferences(this.finalized, finalized)) this.finalized = finalized + } + + private rebuildRunning(): void { + const runningCalls = [...this.runningContributions.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq) + .flatMap(value => value.running === null ? [] : [value.running]) + if (!sameReferences(this.runningCalls, runningCalls)) this.runningCalls = runningCalls + } + + private rebuildPartial(): void { + const partial = [...this.partialContributions.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq) + .findLast(value => value.partial !== null)?.partial ?? null + if (this.partial?.blocks !== partial?.blocks + || this.partial?.turn !== partial?.turn + || this.partial?.step !== partial?.step) this.partial = partial + } + + private updateTimeline(timeline: ConversationTimelineSnapshot): void { + if (this.timeline === timeline) return + this.timeline = timeline + const turnTimings = new Map() + const turnEnds = new Map() + for (const turn of timeline.turns.values()) { + if (turn.start !== undefined) { + turnTimings.set(turn.turn, { + startTime: turn.start.time, + ...turn.end === undefined ? {} : { endTime: turn.end.time }, + }) + } + if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq) + } + this.turnTimings = turnTimings + this.turnEnds = turnEnds + } + + private snapshot(): LegacyConversationSlice { + return { + nodes: this.finalized, + turnTimings: this.turnTimings, + turnEnds: this.turnEnds, + partial: this.partial, + runningCalls: this.runningCalls, + } + } +} + +function updateContributionIndex( + index: Map, + key: string, + contribution: LegacyContribution, + present: boolean, +): void { + if (present) index.set(key, contribution) + else index.delete(key) +} + +function finalizedContributionChanged( + previous: LegacyContribution | undefined, + next: LegacyContribution, +): boolean { + const previousNodes = previous?.nodes ?? EMPTY_LIST + return !sameReferences(previousNodes, next.nodes) + || ((previousNodes.length > 0 || next.nodes.length > 0) && previous?.anchorSeq !== next.anchorSeq) +} + +function runningContributionChanged( + previous: LegacyContribution | undefined, + next: LegacyContribution, +): boolean { + return previous?.running !== next.running + || ((previous.running !== null || next.running !== null) + && previous.anchorSeq !== next.anchorSeq) +} + +function partialContributionChanged( + previous: LegacyContribution | undefined, + next: LegacyContribution, +): boolean { + return previous?.partial?.blocks !== next.partial?.blocks + || previous?.partial?.turn !== next.partial?.turn + || previous?.partial?.step !== next.partial?.step + || (((previous?.partial ?? null) !== null || next.partial !== null) + && previous?.anchorSeq !== next.anchorSeq) +} + +/** Incremental keyed Chat builder registered under the `chat` target. */ +export class ChatSnapshotBuilder implements ConversationViewBuilder { + private readonly store = new MutableChatNodeStore() + private readonly locations = new MutableChatLocationIndex() + private readonly legacy = new LegacySliceBuilder() + private order: readonly string[] = EMPTY_KEYS + readonly empty: ChatSnapshot + + constructor() { + this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() }) + } + + replace(input: { + readonly nodes: readonly ChatConversationViewNode[] + readonly timeline: ConversationTimelineSnapshot + }): ChatSnapshot { + this.store.replace(input.nodes) + this.order = orderedVisible(input.nodes).map(node => node.key) + this.locations.rebuild(this.order, this.store) + return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline)) + } + + apply(input: { + readonly upserts: readonly ChatConversationViewNode[] + readonly timeline: ConversationTimelineSnapshot + }): ChatSnapshot { + let structural = false + const contentOnly: ChatConversationViewNode[] = [] + for (const node of input.upserts) { + const previous = this.store.get(node.key) + const nodeStructural = previous === undefined + || previous.anchorSeq !== node.anchorSeq + || previous.visibility !== node.visibility + || locationIdentity(previous.location) !== locationIdentity(node.location) + structural ||= nodeStructural + if (!nodeStructural) contentOnly.push(node) + } + this.store.upsert(input.upserts) + if (structural) { + const next = orderedVisible(this.store.values()).map(node => node.key) + this.order = sameReferences(this.order, next) ? this.order : next + this.locations.rebuild(this.order, this.store) + } + this.locations.touch(contentOnly) + return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline)) + } + + private snapshot( + timeline: ConversationTimelineSnapshot, + legacy = this.legacy.replace(EMPTY_LIST, timeline), + ): ChatSnapshot { + return { + order: this.order, + nodes: this.store, + locations: this.locations, + timeline, + legacy, + } + } +} + +function locationIdentity(location: ConversationLocation): string { + const coordinates = locationCoordinates(location) + return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}` +} + +/** Chat target factory contributed to the Runtime view registry. */ +export const chatViewDefinition: ConversationViewDefinition = { + target: 'chat', + create: () => new ChatSnapshotBuilder(), +} + +/** + * Register the incremental Chat target builder. + * @param ctx - owning UI Conversation context. + */ +export function registerChatConversationView(ctx: Context): void { + ctx.conversationViews.register(chatViewDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts new file mode 100644 index 0000000000..3ca7976e32 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -0,0 +1,243 @@ +import type { Context } from 'cordis' +import type { + CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Ordinary slash-command lifecycle. */ + command: CommandNode + /** Manual compact command combined with its compaction transaction. */ + 'manual-compaction': ManualCompactionChatData + } +} + +type CommandId = CommandNode['commandId'] + +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' + +interface CommandState { + readonly command: CommandNode + readonly summary?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +interface CompactionEvidence { + readonly summary?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +interface CommandRunData { + readonly commandId: CommandId + readonly name: string + readonly args?: string +} + +interface CommandDoneData { + readonly commandId: CommandId + readonly kind: 'success' | 'error' + readonly text?: string + readonly sourceEventSeq?: number +} + +function commandFromRun(match: ConversationMatch): CommandNode { + const data = match.event.data as unknown as CommandRunData + return { + kind: 'command', + seq: match.event.seq, + time: match.event.time, + commandId: data.commandId, + name: data.name, + args: data.args ?? null, + outcome: null, + } +} + +function commandFromDone(match: ConversationMatch, previous?: CommandNode): CommandNode { + const data = match.event.data as unknown as CommandDoneData + const sourceEventSeq = data.kind === 'success' + && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 + ? data.sourceEventSeq as number + : undefined + return { + kind: 'command', + seq: previous?.seq ?? match.event.seq, + time: previous?.time ?? match.event.time, + commandId: data.commandId, + name: previous?.name ?? null, + args: previous?.args ?? null, + outcome: { + kind: data.kind, + ...data.text === undefined ? {} : { text: data.text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + }, + } +} + +/** + * Read correlation identity from a compaction replacement checkpoint. + * @param event - candidate Session event. + * @returns correlated compaction and optional command identity. + */ +function compactSource(event: Parameters[0]): { + compactionId: string + sourceCommandId?: CommandId +} | undefined { + if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return undefined + const source = event.data.source as unknown as { + kind?: unknown + plugin?: unknown + compactionId?: unknown + sourceCommandId?: CommandId + } + if (source.kind !== 'plugin' || source.plugin !== COMPACT_PLUGIN || typeof source.compactionId !== 'string') return undefined + return { + compactionId: source.compactionId, + ...source.sourceCommandId === undefined ? {} : { sourceCommandId: source.sourceCommandId }, + } +} + +/** + * Build the visible summary marker from optional lifecycle evidence. + * @param match - compact/summary Match, when loaded. + * @param checkpoint - replacement checkpoint Match. + * @returns final compaction summary Node data. + */ +function compactSummary(match: ConversationMatch | undefined, checkpoint: ConversationMatch): CompactionSummaryNode { + let summary: string | null = null + let shadowedItemCount: number | null = null + let shadowedTokenCount: number | null = null + if (match !== undefined) { + const data = match.event.data as unknown as { + summary?: unknown + shadowedSeqs?: unknown + shadowedTokenCount?: unknown + } + if (Array.isArray(data.summary)) { + const text = data.summary + .map((block: unknown) => { + const value = block as { type?: unknown; text?: unknown } + return value.type === 'text' && typeof value.text === 'string' ? value.text : '' + }) + .join('') + summary = text.trim() === '' ? null : text + } + shadowedItemCount = Array.isArray(data.shadowedSeqs) + && data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && (seq as number) >= 0) + ? data.shadowedSeqs.length + : null + shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount) + && (data.shadowedTokenCount as number) >= 0 + ? data.shadowedTokenCount as number + : null + } + return { + kind: 'compaction', + seq: checkpoint.event.seq, + time: checkpoint.event.time, + summary, + summaryEventSeq: match?.event.seq ?? null, + shadowedItemCount, + shadowedTokenCount, + } +} + +function fallbackState(context: ConversationNodeContext): CommandState | undefined { + const done = context.matches.find(match => (match.event.type as string) === 'command/done') + const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) + const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + if (checkpoint === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } + const source = compactSource(checkpoint.event) + if (source?.sourceCommandId === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } + const fallbackCommand = done === undefined + ? { + kind: 'command' as const, + seq: checkpoint.event.seq, + time: checkpoint.event.time, + commandId: source.sourceCommandId, + name: 'compact', + args: null, + outcome: null, + } + : { ...commandFromDone(done), name: 'compact' } + return { + command: fallbackCommand, + checkpoint, + ...summary === undefined ? {} : { summary }, + } +} + +/** + * Fold shared compaction evidence into a Definition-owned State. + * @param state - current business State carrying optional compaction evidence. + * @param match - next compaction lifecycle Match. + * @returns adopted State, preserving reference identity when the Match adds no evidence. + */ +export function updateCompactionState( + state: State, + match: ConversationMatch, +): State { + if ((match.event.type as string) === 'compact/summary') return { ...state, summary: match } + if (compactSource(match.event) !== undefined) return { ...state, checkpoint: match } + return state +} + +/** Slash-command lifecycle, including integrated manual compaction, Definition. */ +export const commandDefinition: ConversationNodeDefinition = { + kind: 'command', + match: (event) => { + if ((event.type as string) === 'command/run') { + return { id: String((event.data as unknown as CommandRunData).commandId), role: 'start' } + } + if ((event.type as string) === 'command/done') { + return { id: String((event.data as unknown as CommandDoneData).commandId), role: 'update' } + } + const checkpoint = compactSource(event) + if (checkpoint?.sourceCommandId !== undefined) { + return { id: String(checkpoint.sourceCommandId), role: 'update' } + } + if ((event.type as string) === 'compact/start' + || (event.type as string) === 'compact/summary' + || (event.type as string) === 'compact/end') { + const data = event.data as unknown as { sourceCommandId?: CommandId } + if (data.sourceCommandId !== undefined) return { id: String(data.sourceCommandId), role: 'update' } + } + return null + }, + start: (_context, match) => ({ command: commandFromRun(match) }), + update: (context, match) => { + if ((match.event.type as string) === 'command/done') { + return { ...context.state, command: commandFromDone(match, context.state.command) } + } + return updateCompactionState(context.state, match) + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + if (state.command.name !== 'compact') { + return chatNode(context, 'command', state.command.seq, state.command) + } + const compaction = state.checkpoint === undefined + ? null + : compactSummary(state.summary, state.checkpoint) + const data: ManualCompactionChatData = { command: state.command, compaction } + return chatNode(context, 'manual-compaction', compaction?.seq ?? state.command.seq, data) + }, +} + +/** + * Register the command lifecycle business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerCommandConversationNode(ctx: Context): void { + ctx.conversationEvents.register(commandDefinition) +} + +/** Shared structural checkpoint recognizer for automatic compaction. */ +export { compactSource, compactSummary } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts new file mode 100644 index 0000000000..8e1d9d7bd4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts @@ -0,0 +1,55 @@ +import type { + ConversationLocation, ConversationNodeContext, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { + ChatNode, ChatNodeDataMap, ChatNodeKind, +} from '../contract/chat-nodes.ts' + +/** + * Resolve one Context's best currently loaded event Location. + * @param context - assembled business Context. + * @returns start or first-match Location, otherwise unresolved. + */ +export function contextLocation(context: ConversationNodeContext): ConversationLocation { + return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } +} + +/** + * Build one final Chat target Node with the engine-owned stable key. + * @param context - assembled business Context. + * @param kind - Chat renderer dispatch key. + * @param anchorSeq - sortable render position. + * @param data - renderer-owned payload. + * @param options - optional Location and visibility overrides. + * @returns final Chat view Node. + */ +export function chatNode( + context: ConversationNodeContext, + kind: Kind, + anchorSeq: number, + data: ChatNodeDataMap[Kind], + options: { + readonly location?: ConversationLocation + readonly visibility?: 'visible' | 'hidden' + } = {}, +): ChatNode { + return { + key: context.key, + kind, + id: context.id, + target: 'chat', + anchorSeq, + location: options.location ?? contextLocation(context), + visibility: options.visibility ?? 'visible', + data, + } +} + +/** + * Read a finite non-negative integer from a structurally narrowed payload. + * @param value - untrusted payload field. + * @returns valid coordinate, otherwise undefined. + */ +export function coordinate(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts new file mode 100644 index 0000000000..0742ab6539 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -0,0 +1,63 @@ +import type { Context } from 'cordis' +import type { + CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' +import { compactSource, compactSummary, updateCompactionState } from './command.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Automatic compaction checkpoint marker. */ + compaction: CompactionSummaryNode + } +} + +interface CompactionState { + readonly summary?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +function fallbackState(context: ConversationNodeContext): CompactionState { + const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) + return { + ...summary === undefined ? {} : { summary }, + ...checkpoint === undefined ? {} : { checkpoint }, + } +} + +/** Automatic compaction lifecycle and landed checkpoint Definition. */ +export const compactionDefinition: ConversationNodeDefinition = { + kind: 'compaction', + match: (event) => { + const checkpoint = compactSource(event) + if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) { + return { id: checkpoint.compactionId, role: 'update' } + } + if ((event.type as string) === 'compact/start' + || (event.type as string) === 'compact/summary' + || (event.type as string) === 'compact/end') { + const data = event.data as unknown as { compactionId?: unknown; sourceCommandId?: unknown } + if (typeof data.compactionId !== 'string' || data.sourceCommandId !== undefined) return null + return { id: data.compactionId, role: (event.type as string) === 'compact/start' ? 'start' : 'update' } + } + return null + }, + start: () => ({}), + update: (context, match) => updateCompactionState(context.state, match), + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state.checkpoint === undefined) return null + const marker = compactSummary(state.summary, state.checkpoint) + return chatNode(context, 'compaction', marker.seq, marker) + }, +} + +/** + * Register the automatic-compaction business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerCompactionConversationNode(ctx: Context): void { + ctx.conversationEvents.register(compactionDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts new file mode 100644 index 0000000000..a93fc8fdd4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts @@ -0,0 +1,40 @@ +import type { Context } from 'cordis' +import type { + ConversationNodeDefinition, UnknownSurfaceNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Generic presentation of an unclaimed append-surface event. */ + unknown: UnknownSurfaceNode + } +} + +/** Unclaimed append-surface fallback Definition. */ +export const unknownFallbackDefinition: ConversationNodeDefinition = { + kind: 'unknown-surface', + match: event => isAppendSurfaceEvent(event) + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match) => ({ + kind: 'unknown', + seq: match.event.seq, + time: match.event.time, + type: match.event.type, + data: match.event.data, + }), + update: context => context.state, + buildViewNode: (context, target) => target !== 'chat' || context.state === undefined + ? null + : chatNode(context, 'unknown', context.state.seq, context.state), +} + +/** + * Register the unmatched append-surface fallback contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerUnknownConversationFallback(ctx: Context): void { + ctx.conversationEvents.registerFallback(unknownFallbackDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts new file mode 100644 index 0000000000..092bcaaa59 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -0,0 +1,71 @@ +import type { Context } from 'cordis' +import type { + ConversationNodeDefinition, ConversationPreviousContext, +} from '@deepseek-ai/dsh-client-runtime/client' + +type InboxTarget = 'next-turn' | 'next-step' + +interface InboxIdentity { + readonly id: string +} + +interface InboxSplice { + readonly target: InboxTarget + readonly start: number + readonly removedCount?: number + readonly inserted: readonly InboxIdentity[] + readonly outcome?: 'canceled' +} + +/** Cumulative state after one durable inbox splice. */ +export interface InboxState { + readonly pending: readonly InboxIdentity[] + readonly claimed: ReadonlySet +} + +function applySplice( + previous: ConversationPreviousContext | undefined, + splice: InboxSplice, +): InboxState { + const pending = [...(previous?.state.pending ?? [])] + const claimed = new Set(previous?.state.claimed ?? []) + const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) + for (const identity of splice.inserted) claimed.delete(identity.id) + if (splice.target === 'next-step' && splice.outcome !== 'canceled') { + for (const identity of removed) claimed.add(identity.id) + } + return { pending, claimed } +} + +function inboxDefinition(target: InboxTarget): ConversationNodeDefinition { + const kind = `inbox-${target}` + return { + kind, + match: event => (event.type as string) === 'agent/inbox/spliced' + && (event.data as unknown as { target?: unknown }).target === target + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => applySplice( + reader.previous(kind), + match.event.data as unknown as InboxSplice, + ), + update: context => context.state, + publication: () => 'none', + buildViewNode: () => null, + } +} + +/** Cumulative next-turn inbox splice Definition. */ +export const nextTurnInboxDefinition = inboxDefinition('next-turn') + +/** Cumulative next-step inbox splice Definition used to classify steering. */ +export const nextStepInboxDefinition = inboxDefinition('next-step') + +/** + * Register the two durable Inbox-state contributions. + * @param ctx - owning UI Conversation context. + */ +export function registerInboxConversationNodes(ctx: Context): void { + ctx.conversationEvents.register(nextTurnInboxDefinition) + ctx.conversationEvents.register(nextStepInboxDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts new file mode 100644 index 0000000000..91300944d7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -0,0 +1,83 @@ +import type { Context } from 'cordis' +import type { + ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxState } from './inbox.ts' +import { chatNode } from './common.ts' + +type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Ordinary turn-opening user message. */ + user: UserMessageNode + /** User message admitted into an active turn. */ + steering: SteeringMessageNode + /** Non-user context injected into model history. */ + context: ContextMessageNode + } +} + +function isCompactionCheckpoint(event: Parameters[0]): boolean { + if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return false + const source = event.data.source + return source.kind === 'plugin' && source.plugin === 'compact' +} + +/** User, steering, and injected-context message classification Definition. */ +export const messageDefinition: ConversationNodeDefinition = { + kind: 'input-message', + match: event => event.type === 'user/message' + && isAppendSurfaceEvent(event) + && !isCompactionCheckpoint(event) + ? { id: String(event.data.id), role: 'start' } + : null, + start: (_context, match, reader) => { + if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message') + const event = match.event + if (event.data.source.kind !== 'user') { + return { + kind: 'context', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), + } + } + const claimed = reader.previous('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true + return claimed + ? { + kind: 'steering', + messageId: event.data.id, + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + : { + kind: 'user', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + }, + update: context => context.state, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined) return null + return chatNode(context, context.state.kind, context.state.seq, context.state) + }, +} + +/** + * Register the user, steering, and injected-context message contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerMessageConversationNode(ctx: Context): void { + ctx.conversationEvents.register(messageDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts new file mode 100644 index 0000000000..9102b1f2f4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts @@ -0,0 +1,30 @@ +import type { Context } from 'cordis' +import { registerAssistantConversationNode } from './assistant.ts' +import { registerChatConversationView } from './chat-snapshot-builder.ts' +import { registerCommandConversationNode } from './command.ts' +import { registerCompactionConversationNode } from './compaction.ts' +import { registerUnknownConversationFallback } from './fallback.ts' +import { registerInboxConversationNodes } from './inbox.ts' +import { registerMessageConversationNode } from './message.ts' +import { registerRetryConversationNode } from './retry.ts' +import { registerToolConversationNode } from './tool.ts' +import { registerTurnErrorConversationNode } from './turn-error.ts' +import { registerTurnTailConversationNode } from './turn-tail.ts' + +/** + * Register the Chat business Definitions and target builder contributed by this package. + * @param ctx - owning UI Conversation context. + */ +export function registerConversationNodes(ctx: Context): void { + registerInboxConversationNodes(ctx) + registerMessageConversationNode(ctx) + registerAssistantConversationNode(ctx) + registerToolConversationNode(ctx) + registerCommandConversationNode(ctx) + registerCompactionConversationNode(ctx) + registerRetryConversationNode(ctx) + registerTurnErrorConversationNode(ctx) + registerTurnTailConversationNode(ctx) + registerUnknownConversationFallback(ctx) + registerChatConversationView(ctx) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts new file mode 100644 index 0000000000..fe95c0052d --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -0,0 +1,116 @@ +import type { Context } from 'cordis' +import type { + ConversationLocation, ConversationNodeDefinition, ModelRetryNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { RetryChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Producer-correlated model retry chain. */ + 'model-retry': RetryChatData + } +} + +type WithoutRetryProjection = Node extends unknown + ? Omit + : never +type RetryEventData = WithoutRetryProjection + +/** Accumulated retry attempts sharing one producer-owned RetryId. */ +export interface RetryState { + readonly turn: number + readonly step: number + readonly attempts: readonly ModelRetryNode[] +} + +function retryData(value: unknown): RetryEventData | undefined { + if (value === null || typeof value !== 'object') return undefined + const data = value as Record + if (typeof data.retryId !== 'string' || data.retryId === '' + || !Number.isSafeInteger(data.turn) || (data.turn as number) < 0 + || !Number.isSafeInteger(data.step) || (data.step as number) < 0 + || !Number.isSafeInteger(data.retry) || (data.retry as number) <= 0 + || typeof data.delayMs !== 'number' || !Number.isFinite(data.delayMs) || data.delayMs < 0 + || typeof data.provider !== 'string' || typeof data.policyKey !== 'string' + || (data.mode !== 'normal' && data.mode !== 'always') + || data.failure === null || typeof data.failure !== 'object') return undefined + if (data.mode === 'normal' && (!Number.isSafeInteger(data.maxRetries) || (data.maxRetries as number) <= 0)) { + return undefined + } + return data as unknown as RetryEventData +} + +function scheduledNode(event: { seq: number; time: number; data: unknown }): ModelRetryNode | undefined { + const data = retryData(event.data) + return data === undefined ? undefined : { + kind: 'model-retry', + seq: event.seq, + time: event.time, + retryState: 'scheduled', + ...data, + } +} + +function isClosed(location: ConversationLocation): boolean { + return (location.kind === 'step' && location.step.status === 'closed') + || ((location.kind === 'step' || location.kind === 'turn') && location.turn.status === 'closed') +} + +/** Producer-correlated model retry chain Definition. */ +export const retryDefinition: ConversationNodeDefinition = { + kind: 'model-retry', + match: (event) => { + if ((event.type as string) === 'llm/retry') { + const data = retryData(event.data) + if (data === undefined) return null + return { id: String(data.retryId), role: data.retry === 1 ? 'start' : 'update' } + } + if ((event.type as string) === 'llm/retry-started') { + const data = event.data as unknown as { retryId?: unknown } + return typeof data.retryId === 'string' ? { id: data.retryId, role: 'update' } : null + } + return null + }, + start: (_context, match) => { + const node = scheduledNode(match.event) + if (node === undefined) throw new Error('model-retry start requires a valid llm/retry event') + return { turn: node.turn, step: node.step, attempts: [node] } + }, + update: (context, match) => { + if ((match.event.type as string) === 'llm/retry') { + const node = scheduledNode(match.event) + return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] } + } + if ((match.event.type as string) !== 'llm/retry-started') return context.state + const data = match.event.data as unknown as { retry: number } + return { + ...context.state, + attempts: context.state.attempts.map(attempt => + attempt.retry === data.retry ? { ...attempt, retryState: 'started' } : attempt), + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null + const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const } + const stateAttempts = context.state.attempts + const attempts = stateAttempts.map((attempt, index) => + index === stateAttempts.length - 1 + && attempt.retryState === 'scheduled' + && isClosed(location) + ? { ...attempt, retryState: 'cancelled' as const } + : attempt) + const current = attempts.at(-1) + if (current === undefined) return null + const data: RetryChatData = { attempts, current } + return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data) + }, +} + +/** + * Register the correlated model-retry business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerRetryConversationNode(ctx: Context): void { + ctx.conversationEvents.register(retryDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts new file mode 100644 index 0000000000..04a5770f5a --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -0,0 +1,271 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + RunningToolCall, ToolCallBlock, ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Root Tool lifecycle with recursively nested subcalls. */ + 'tool-call': ToolChatData + } +} + +const MAX_DEPTH = 256 + +interface ToolState { + readonly root: ToolCallBlock + readonly children: ReadonlyMap + readonly parents: ReadonlyMap +} + +interface ProjectedBlockCache { + readonly children: readonly ToolCallBlock[] + readonly interruptionSeq: number | undefined + readonly interruptionTime: number | undefined + readonly value: ToolCallBlock +} + +const projectedBlocks = new WeakMap() + +function jsonArguments(value: unknown): string { + return JSON.stringify(value) +} + +function rootCall(match: ConversationMatch): RunningToolCall { + if (match.event.type !== 'tool/call') throw new Error('tool-call start requires tool/call') + return { + callId: String(match.event.data.callId), + name: match.event.data.name, + argsRaw: match.event.data.arguments, + turn: match.event.data.turn, + step: match.event.data.step, + time: match.event.time, + callView: match.view?.for === 'call' ? match.view.view : null, + subCalls: [], + } +} + +function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined { + if (match.event.type !== 'tool/result') return undefined + const result = match.event.data.message.content[0] + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: String(match.event.data.message.source.callId), + call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw }, + callTime: previous?.time ?? null, + content: result.content, + isError: result.isError === true, + ...match.event.data.error === undefined ? {} : { error: match.event.data.error }, + meta: match.event.data.meta, + callView: previous?.callView ?? null, + resultView: match.view?.for === 'result' ? match.view.view : null, + subCalls: [], + } +} + +interface DispatchData { + readonly parentCallId: string + readonly subCallId: string + readonly name: string + readonly arguments: unknown + readonly isError?: boolean + readonly content?: ToolResultNode['content'] +} + +function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { + return { + callId: data.subCallId, + name: data.name, + argsRaw: jsonArguments(data.arguments), + turn: locationTurn(match), + step: locationStep(match), + time: match.event.time, + callView: null, + subCalls: [], + } +} + +function childResult(match: ConversationMatch, data: DispatchData, previous?: ToolCallBlock): ToolResultNode { + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: data.subCallId, + call: { name: data.name, argsRaw: jsonArguments(data.arguments) }, + callTime: previous?.time ?? null, + content: data.content ?? [], + isError: data.isError === true, + callView: null, + resultView: null, + subCalls: [], + } +} + +function locationTurn(match: ConversationMatch): number { + return match.location.kind === 'step' || match.location.kind === 'turn' ? match.location.turn.turn : 0 +} + +function locationStep(match: ConversationMatch): number { + return match.location.kind === 'step' ? match.location.step.step : 0 +} + +function acceptsEdge(state: ToolState, parent: string, child: string): boolean { + if (parent === child || state.parents.has(child)) return false + let cursor: string | undefined = parent + let parentDepth = 0 + const ancestors = new Set() + while (cursor !== undefined) { + if (cursor === child || ancestors.has(cursor)) return false + ancestors.add(cursor) + parentDepth++ + cursor = state.parents.get(cursor) + } + const pending = [{ callId: child, depth: 1 }] + const descendants = new Set() + let subtreeDepth = 0 + for (const candidate of pending) { + if (descendants.has(candidate.callId)) return false + descendants.add(candidate.callId) + subtreeDepth = Math.max(subtreeDepth, candidate.depth) + for (const nested of state.children.get(candidate.callId) ?? []) { + pending.push({ callId: nested.callId, depth: candidate.depth + 1 }) + } + } + return parentDepth + subtreeDepth <= MAX_DEPTH +} + +function updateDispatch(state: ToolState, match: ConversationMatch): ToolState { + const data = match.event.data as unknown as DispatchData + const siblings = state.children.get(data.parentCallId) ?? [] + const index = siblings.findIndex(candidate => candidate.callId === data.subCallId) + if ((match.event.type as string) === 'tool/code-dispatch-start') { + if (index >= 0 || !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + const children = new Map(state.children) + children.set(data.parentCallId, [...siblings, childCall(match, data)]) + const parents = new Map(state.parents) + parents.set(data.subCallId, data.parentCallId) + return { ...state, children, parents } + } + if ((match.event.type as string) !== 'tool/code-dispatch') return state + if (index < 0 && !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + const previous = index < 0 ? undefined : siblings[index] + const settled = childResult(match, data, previous) + const children = new Map(state.children) + children.set(data.parentCallId, index < 0 + ? [...siblings, settled] + : siblings.map((child, at) => at === index ? settled : child)) + const parents = new Map(state.parents) + if (index < 0) parents.set(data.subCallId, data.parentCallId) + return { ...state, children, parents } +} + +function projectBlock( + block: ToolCallBlock, + state: ToolState, + interruptedAt: { seq: number; time: number } | undefined, + visited = new Set(), + depth = 1, +): ToolCallBlock { + if (visited.has(block.callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] } + const nextVisited = new Set(visited) + nextVisited.add(block.callId) + const children = (state.children.get(block.callId) ?? block.subCalls) + .map(child => projectBlock(child, state, interruptedAt, nextVisited, depth + 1)) + const interruptionSeq = 'kind' in block ? undefined : interruptedAt?.seq + const interruptionTime = 'kind' in block ? undefined : interruptedAt?.time + const cached = projectedBlocks.get(block) + if (cached !== undefined + && cached.interruptionSeq === interruptionSeq + && cached.interruptionTime === interruptionTime + && sameReferences(cached.children, children)) { + return cached.value + } + const projected: ToolCallBlock = 'kind' in block || interruptedAt === undefined + ? sameReferences(block.subCalls, children) ? block : { ...block, subCalls: children } + : { + kind: 'tool-result', + seq: interruptedAt.seq - 0.8, + time: interruptedAt.time, + callId: block.callId, + call: { name: block.name, argsRaw: block.argsRaw }, + callTime: block.time, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: block.callView, + resultView: null, + subCalls: children, + } + projectedBlocks.set(block, { children, interruptionSeq, interruptionTime, value: projected }) + return projected +} + +function sameReferences(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function interruption(context: ConversationNodeContext): { seq: number; time: number } | undefined { + const location = context.start?.location + if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end + if ((location?.kind === 'step' || location?.kind === 'turn') && location.turn.status === 'closed') { + return location.turn.end + } + return undefined +} + +function fallbackState(context: ConversationNodeContext): ToolState | undefined { + const match = context.matches.find(candidate => candidate.event.type === 'tool/result') + const root = match === undefined ? undefined : rootResult(match) + if (root === undefined) return undefined + let state: ToolState = { root, children: new Map(), parents: new Map() } + for (const candidate of context.matches) state = updateDispatch(state, candidate) + return state +} + +/** Root Tool lifecycle and nested Code Dispatch Definition. */ +export const toolDefinition: ConversationNodeDefinition = { + kind: 'tool-call', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { + return { id: String(event.data.message.source.callId), role: 'update' } + } + if ((event.type as string) === 'tool/code-dispatch-start' || (event.type as string) === 'tool/code-dispatch') { + const data = event.data as unknown as { rootCallId: string } + return { id: data.rootCallId, role: 'update' } + } + return null + }, + start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }), + update: (context, match) => { + if (match.event.type === 'tool/result') { + const running = 'kind' in context.state.root ? undefined : context.state.root + const result = rootResult(match, running) + return result === undefined ? context.state : { ...context.state, root: result } + } + return updateDispatch(context.state, match) + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const projected = projectBlock(state.root, state, interruption(context)) + const anchor = context.start?.event.seq + ?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0) + return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData) + }, +} + +/** + * Register the root Tool lifecycle and nested-subcall contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerToolConversationNode(ctx: Context): void { + ctx.conversationEvents.register(toolDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts new file mode 100644 index 0000000000..58725d8723 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts @@ -0,0 +1,112 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Terminal turn failure not superseded by retry. */ + 'turn-error': TurnErrorNode + } +} + +interface TurnErrorState { + readonly turn: number + readonly hidden: boolean + readonly failure?: { + readonly seq: number + readonly time: number + readonly message: string + readonly code?: string + } +} + +function lastStep(context: ConversationNodeContext): number { + const location = context.start?.location ?? context.matches[0]?.location + if (location?.kind !== 'turn' && location?.kind !== 'step') return 0 + return location.turn.steps.at(-1)?.step ?? 0 +} + +function retryTurn(event: Parameters[0]): number | undefined { + if ((event.type as string) !== 'llm/retry' && (event.type as string) !== 'llm/retry-started') return undefined + const turn = (event.data as unknown as { turn?: unknown }).turn + return Number.isSafeInteger(turn) && (turn as number) >= 0 ? turn as number : undefined +} + +function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { + if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined + const failure = match.event.data.reason.error + return { + seq: match.event.seq, + time: match.event.time, + message: displayFailureMessage(failure), + code: failure.code, + } +} + +function fallbackState(context: ConversationNodeContext): TurnErrorState | undefined { + const end = context.matches.find(match => failureFrom(match) !== undefined) + if (end?.event.type !== 'turn/end') return undefined + const failure = failureFrom(end) + if (failure === undefined) return undefined + const turn = end.event.data.turn + return { + turn, + hidden: context.matches.some(match => retryTurn(match.event) === turn), + failure, + } +} + +/** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */ +export const turnErrorDefinition: ConversationNodeDefinition = { + kind: 'turn-error', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + if (event.type === 'turn/end' && event.data.reason.kind === 'error') { + return { id: String(event.data.turn), role: 'update' } + } + const turn = retryTurn(event) + return turn === undefined ? null : { id: String(turn), role: 'update' } + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('turn-error start requires turn/start') + return { turn: match.event.data.turn, hidden: false } + }, + update: (context, match) => { + const failure = failureFrom(match) + if (failure !== undefined) return { ...context.state, failure } + return retryTurn(match.event) === context.state.turn + ? { ...context.state, hidden: true } + : context.state + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state?.failure === undefined) return null + const failure = state.failure + const node: TurnErrorNode = { + kind: 'turn-error', + seq: failure.seq, + time: failure.time, + turn: state.turn, + step: lastStep(context), + message: failure.message, + ...failure.code === undefined ? {} : { code: failure.code }, + } + if (!state.hidden) return chatNode(context, 'turn-error', node.seq, node) + const current = context.current.get('chat') + return current === undefined || current === null + ? null + : chatNode(context, 'turn-error', node.seq, node, { visibility: 'hidden' }) + }, +} + +/** + * Register the terminal Turn-error business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnErrorConversationNode(ctx: Context): void { + ctx.conversationEvents.register(turnErrorDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts new file mode 100644 index 0000000000..2a842a005d --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -0,0 +1,180 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantChatData, FinalAssistantChatData, TurnTailChatData, +} from '../contract/chat-nodes.ts' +import { deriveTurnMetrics } from '../chat/turn-metrics.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Completed-turn actions and extension tail. */ + 'turn-tail': TurnTailChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationTurnDataMap { + /** Closing Assistant and footer facts derived for this completed Turn. */ + 'turn-tail': TurnTailChatData + } +} + +interface TurnTailState { + readonly turn: number + readonly end?: ConversationMatch +} + +interface StepEvidence { + readonly streamedText: boolean + readonly finalized: boolean +} + +function hasTextAssistant(event: Parameters[0]): boolean { + return event.type === 'assistant/message' + && isAppendSurfaceEvent(event) + && toAssistantBlocks(event.data.message.content) + .some(block => block.kind === 'text' && block.text.trim() !== '') +} + +function chunkHasText(event: Parameters[0]): boolean { + if (event.type !== 'assistant/chunk') return false + const chunk = event.data.chunk + if (chunk.type === 'text-delta') return chunk.text.trim() !== '' + return chunk.type === 'block-end' + && chunk.block.type === 'text' + && chunk.block.text.trim() !== '' +} + +function turnCoordinates(event: Parameters[0]): { + readonly turn: number + readonly step?: number +} | undefined { + if (event.type === 'assistant/message' + || event.type === 'assistant/chunk' + || event.type === 'step/end') { + return { turn: event.data.turn, step: event.data.step } + } + if ((event.type as string) === 'llm/retry') { + return event.data as unknown as { turn: number; step: number } + } + return undefined +} + +function closingAnchor(context: ConversationNodeContext): number { + let anchor = context.matches.find(match => match.event.type === 'turn/end')?.event.seq + ?? context.start?.event.seq + ?? context.matches[0]?.event.seq + ?? 0 + const steps = new Map() + for (const match of context.matches) { + const event = match.event + if (event.type === 'turn/end') continue + const coordinates = turnCoordinates(event) + if (coordinates?.step === undefined) continue + const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false } + if (event.type === 'assistant/chunk') { + steps.set(coordinates.step, { + ...previous, + streamedText: previous.streamedText || chunkHasText(event), + }) + continue + } + if (event.type === 'assistant/message') { + steps.set(coordinates.step, { streamedText: false, finalized: true }) + if (hasTextAssistant(event)) anchor = event.seq + 0.1 + continue + } + if ((event.type as string) === 'llm/retry') { + steps.set(coordinates.step, { streamedText: false, finalized: false }) + continue + } + if (event.type === 'step/end' && previous.streamedText && !previous.finalized) { + anchor = event.seq - 0.8 + } + } + return anchor +} + +function turnLocation(context: ConversationNodeContext): TurnLocation | undefined { + const location = context.start?.location ?? context.matches[0]?.location + return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined +} + +function hasText(data: AssistantChatData): data is FinalAssistantChatData { + return data.finalNode !== undefined + && data.blocks.some(block => block.kind === 'text' && block.text.trim() !== '') +} + +function tailData(context: ConversationNodeContext): TurnTailChatData | null { + const end = context.state?.end + ?? context.matches.find(match => match.event.type === 'turn/end') + if (end?.event.type !== 'turn/end') return null + const turn = turnLocation(context) + if (turn === undefined) return null + const assistants = turn.steps + .map(step => step.data.get('assistant-step')) + .filter((candidate): candidate is Readonly => candidate !== undefined) + const finalized = assistants + .filter((candidate): candidate is Readonly => candidate.finalNode !== undefined) + .sort((left, right) => left.finalNode.seq - right.finalNode.seq) + const closing = finalized.findLast(hasText) ?? null + const latest = finalized.at(-1) + const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn) + return { + turn: end.event.data.turn, + seq: end.event.seq, + time: end.event.time, + closing, + branchUnavailable: closing === null || latest?.finalNode.seq !== closing.finalNode.seq, + ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs }, + ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond }, + } +} + +/** Completed-turn footer Definition independent of any Assistant row. */ +export const turnTailDefinition: ConversationNodeDefinition = { + kind: 'turn-tail', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' } + const coordinates = turnCoordinates(event) + if (coordinates !== undefined) return { id: String(coordinates.turn), role: 'update' } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('turn-tail start requires turn/start') + return { turn: match.event.data.turn } + }, + update: (context, match) => match.event.type === 'turn/end' + ? { ...context.state, end: match } + : context.state, + publication: match => match.event.type === 'turn/end' ? 'immediate' : 'none', + buildLocationData: (context, scope) => { + if (scope !== 'turn') return null + const value = tailData(context) + return value === null ? null : { + kind: 'turn', + turn: value.turn, + key: 'turn-tail', + value, + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const turn = turnLocation(context) + const data = turn?.data.get('turn-tail') + return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data) + }, +} + +/** + * Register completed-Turn footer data and its Chat node contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnTailConversationNode(ctx: Context): void { + ctx.conversationEvents.register(turnTailDefinition) +} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index a19cd77753..b4d7467170 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -5,6 +5,17 @@ */ export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' +export { registerAssistantConversationNode } from './conversation-nodes/assistant.ts' +export { registerChatConversationView } from './conversation-nodes/chat-snapshot-builder.ts' +export { registerCommandConversationNode } from './conversation-nodes/command.ts' +export { registerCompactionConversationNode } from './conversation-nodes/compaction.ts' +export { registerUnknownConversationFallback } from './conversation-nodes/fallback.ts' +export { registerInboxConversationNodes } from './conversation-nodes/inbox.ts' +export { registerMessageConversationNode } from './conversation-nodes/message.ts' +export { registerRetryConversationNode } from './conversation-nodes/retry.ts' +export { registerToolConversationNode } from './conversation-nodes/tool.ts' +export { registerTurnErrorConversationNode } from './conversation-nodes/turn-error.ts' +export { registerTurnTailConversationNode } from './conversation-nodes/turn-tail.ts' export type { IConversation } from './service.ts' export type { @@ -12,12 +23,16 @@ export type { } from './contract/views.ts' export type { ConversationKey } from './locales.ts' export type { - ChatFileMentions, + AssistantChatData, ChatNode, ChatNodeDataMap, ChatNodeKind, ManualCompactionChatData, + RetryChatData, ToolChatData, TurnTailChatData, +} from './contract/chat-nodes.ts' +export type { + ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, - ToolTreeOwnerProps, TurnTailOwnerProps, + TurnTailOwnerProps, UseChatNodeTurnData, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx index a1a0a120f5..98e98f0f01 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx @@ -17,6 +17,7 @@ import { useMemo, useState } from 'react' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' +import { rootToolCall } from '../chat/tool-node-reader.ts' import css from './ApprovalPanel.module.css' /** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */ @@ -40,8 +41,12 @@ export function commandOf(call: RunningToolCall | undefined): string | undefined */ export function ApprovalPanel(props: ApprovalComposerProps) { const approval = useMemo(() => new PendingApproval(props.matched), [props.matched]) - const command = props.useSession(s => commandOf( - approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId))) + const command = props.useSession((snapshot) => { + if (approval.callId === undefined) return undefined + const root = rootToolCall(snapshot, approval.callId) + if (root === undefined) return undefined + return root.callId === approval.callId && !('kind' in root) ? commandOf(root) : undefined + }) return } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index a04e9ba9e8..1caefd2ecf 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -12,6 +12,7 @@ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { findToolCall } from '../chat/tool-node-reader.ts' import css from './DetailsPanel.module.css' /** Full props composed by reference from the contract (automatic shares & injected share). */ @@ -40,30 +41,10 @@ function runningMaterial(call: RunningToolCall): CallMaterial { return { name: call.name, argsRaw: call.argsRaw, block: call } } -function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined { - if (block.callId === callId) return block - for (const child of block.subCalls) { - const found = findCall(child, callId) - if (found !== undefined) return found - } - return undefined -} - function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { - for (const node of s.nodes) { - if (node.kind !== 'tool-result') continue - const found = findCall(node, callId) - if (found !== undefined) { - return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) - } - } - for (const root of s.runningCalls) { - const found = findCall(root, callId) - if (found !== undefined) { - return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) - } - } - return null + const found = findToolCall(s, callId) + if (found === undefined) return null + return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) } function pretty(raw: string): string { diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts index f9a7d46553..66c54081ab 100644 --- a/packages/client/ui-conversation/src/invariant.ts +++ b/packages/client/ui-conversation/src/invariant.ts @@ -17,7 +17,7 @@ export const inject = ['invariants'] /** * No runtime invariant: the conversation service emits no cordis events, and * both rings this package owns (the 'conversation.view' tab ring and the - * 'conversation.chat.tool' whole-call seat) ride the slot system, whose ledger + * 'conversation.chat.node' business renderer seat) ride the slot system, whose ledger * invariants live with the runtime slots package. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 81b2f61b79..fb8bdd0224 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -12,7 +12,9 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/c import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts' +import { + deliverablesDefinition, producedFileMentions, selectProducedFiles, +} from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -25,13 +27,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' export { producedForClosing } from './turn-deliverables.ts' /** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale'] +export const inject = ['slots', 'locale', 'conversationEvents'] /** * Client plugin body: register the dictionaries and the turn-tail entry. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( 'conversation.chat.turnTail', diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 4316ddf8e6..0227b34261 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -1,12 +1,37 @@ /** - * Pure derivation of one turn's produced files from finalized snapshot - * nodes. Client-only and model-free: the vocabulary is the mutation tools' - * own follow-along `locations`, never the closing prose. + * Turn-scoped produced-file Definition and readers. Client-only and + * model-free: the vocabulary is the mutation tools' own follow-along + * `locations`, never the closing prose. */ -import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationNodeDefinition, ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +interface ProducedPath { + readonly seq: number + readonly path: string +} + +/** Immutable produced-file facts published against one Turn. */ +export interface DeliverablesTurnData { + readonly produced: readonly ProducedPath[] +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationTurnDataMap { + /** Successful mutation paths accumulated in this Turn. */ + deliverables: DeliverablesTurnData + } +} + +interface DeliverablesState extends DeliverablesTurnData { + readonly turn: number + readonly calls: ReadonlyMap +} + /** * Paths a call view reports having created or changed, by render intent rather * than tool name: a diff card, or a generic card whose kind is `edit` (the @@ -23,9 +48,7 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] { } /** - * Files produced by the turn the assistant at `seq` closes — the anchor the - * render site elects, so the row lands under the message that reports the - * work rather than after some mid-turn narration. + * Files produced by one Turn data value. * * The source is the mutation tools' own follow-along `locations`, not the * closing prose: a produced file must be listed whether or not the model @@ -37,46 +60,26 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] { * failed calls. Paths keep first-seen order and appear once, so a file written * and then edited in the same turn is one entry. * - * Accumulation resets on the turn boundary — a user message, or a node - * reporting a different turn number — so a turn that mutates files and then - * ends without content text cannot spill its paths into the next turn's row, - * nor leave the dedup set suppressing a file the next turn legitimately - * rewrites. Tool results carry no turn of their own; the boundary is read off - * the nodes that do, and a user message resets the tracked turn to undefined - * because the next node to report one is stating the current turn, not - * entering a new one. - * @param nodes - snapshot nodes (surface order). - * @param seq - the closing assistant's seq (the render site's anchor). + * The Conversation Location index owns turn membership before this function + * runs, so paths cannot spill across turns and this derivation does not infer + * boundaries from neighboring presentation Nodes. + * @param data - engine-published Deliverables data for one Turn. + * @param seq - closing Assistant seq; later Tool settlements are excluded. * @returns Produced paths in first-seen order; empty when the turn wrote nothing. */ -export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] { - let pending: string[] = [] - let seen = new Set() - let turn: number | undefined - for (const node of nodes) { - if (node.kind === 'tool-result') { - if (node.isError) continue - for (const path of producedPaths(node.callView)) { - if (seen.has(path)) continue - seen.add(path) - pending.push(path) - } - continue - } - if (node.kind === 'user') { - turn = undefined - pending = [] - seen = new Set() - } else if ('turn' in node) { - if (turn !== undefined && node.turn !== turn) { - pending = [] - seen = new Set() - } - turn = node.turn - } - if (node.kind === 'assistant' && node.seq === seq) return pending +export function producedForClosing( + data: Readonly | undefined, + seq = Number.POSITIVE_INFINITY, +): readonly string[] { + if (data === undefined) return [] + const paths: string[] = [] + const seen = new Set() + for (const produced of data.produced) { + if (produced.seq > seq || seen.has(produced.path)) continue + seen.add(produced.path) + paths.push(produced.path) } - return [] + return paths } /** @@ -85,11 +88,55 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb * @returns Produced paths as the component's match, or null to decline before mount. */ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null { - const { nodes, seq } = owner - const paths = producedForClosing(nodes, seq) + const paths = producedForClosing(owner.turn.data.get('deliverables'), owner.seq) return paths.length === 0 ? null : paths } +/** Turn-local successful mutation accumulator; it publishes no view Node. */ +export const deliverablesDefinition: ConversationNodeDefinition = { + kind: 'deliverables', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' } + if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { + return { id: String(event.data.turn), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('deliverables start requires turn/start') + return { turn: match.event.data.turn, calls: new Map(), produced: [] } + }, + update: (context, match) => { + if (match.event.type === 'tool/call') { + const calls = new Map(context.state.calls) + calls.set( + String(match.event.data.callId), + match.view?.for === 'call' ? match.view.view : null, + ) + return { ...context.state, calls } + } + if (match.event.type !== 'tool/result') return context.state + const result = match.event.data.message.content[0] + if (result.isError === true) return context.state + const callId = String(match.event.data.message.source.callId) + const additions = producedPaths(context.state.calls.get(callId) ?? null) + .map(path => ({ seq: match.event.seq, path })) + return additions.length === 0 + ? context.state + : { ...context.state, produced: [...context.state.produced, ...additions] } + }, + buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined + ? null + : { + kind: 'turn', + turn: context.state.turn, + key: 'deliverables', + value: { produced: context.state.produced }, + }, + buildViewNode: () => null, +} + /** * Trailing path segment, the part that identifies the file at a glance. * @param path - Slash- or backslash-separated path. diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 48a9a4c812..ec2f0b8ec1 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -20,8 +20,9 @@ export const inject = ['slots'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - ctx.slots.inject('conversation.chat.tool', () => ctx.slots.register({ - name: 'conversation.chat.tool', + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'tool-call', locale: NS, children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 4b74055b2c..26039efc6f 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -30,8 +30,8 @@ export interface ToolCallOwnerProps { /** Full props of a registered atomic Tool view. */ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> -/** Full props of the Tool call-tree renderer registered into the chat flow. */ -export type ToolTreeProps = PropsRuntime<'conversation.chat.tool'> +/** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ +export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 278be7dacf..db3ed0af06 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -88,8 +88,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t, }: ToolTreeProps) { + const block = node.data.root return ( Date: Sun, 9 Aug 2026 15:48:56 +0800 Subject: [PATCH 08/32] test: update conversation assembly coverage and docs --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 27 +- ...26-07-19-gui-web-client-architecture.zh.md | 29 +- ...ient-tool-presentation-ownership.i18n.yaml | 4 +- ...8-08-client-tool-presentation-ownership.md | 101 +- ...8-client-tool-presentation-ownership.zh.md | 103 +- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 2 +- ...eb-transcript-log-ordered-projection.zh.md | 2 +- apps/web/tests/built-boot.snapshot.ts | 1 - .../tests/chat-continuous-conversation.e2e.ts | 20 +- apps/web/tests/chat-long-interactions.e2e.ts | 49 +- apps/web/tests/search-card.snapshot.ts | 4 +- apps/web/tests/seeded-history.e2e.ts | 17 +- apps/web/tests/skill-user-invoke.e2e.ts | 2 + .../snapshots/code-mode-round/session.jsonl | 8 +- apps/web/tests/todo-row.snapshot.ts | 2 +- docs/config-catalog.md | 4 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 249 ++--- docs/persistence-catalog.md | 54 +- docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 6 +- docs/subsystems/commands.zh.md | 6 +- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 11 +- docs/subsystems/compaction.zh.md | 11 +- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 9 +- docs/subsystems/tools.zh.md | 9 +- .../advanced-toolchain/session.jsonl | 4 +- .../snapshots/both-mode-turn/session.jsonl | 4 +- .../snapshots/code-mode-turn/session.jsonl | 8 +- .../code-mode-workspace-context/session.jsonl | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../empty-response-retry/session.jsonl | 19 +- .../advanced-toolchain/session.jsonl | 4 +- .../stream-json.expected.jsonl | 4 +- .../compaction-recovery/session.jsonl | 20 +- .../stream-json.expected.jsonl | 8 +- .../provider-retry/stream-json.expected.jsonl | 19 +- packages/bash/bash-env/tests/bash-env.spec.ts | 1 + .../client/connection/src/client/fixture.ts | 48 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 10 +- packages/client/runtime/README.zh.md | 10 +- .../client/runtime/tests/client-apply.spec.ts | 29 +- .../tests/compact-checkpoint-pin.spec.ts | 52 - .../tests/conversation-assembler.spec.ts | 892 ++++++++++++++++++ .../tests/conversation-registry.spec.ts | 126 +++ packages/client/runtime/tests/event-script.ts | 4 +- .../client/runtime/tests/queue-store.spec.ts | 1 - packages/client/runtime/tests/session.spec.ts | 808 ++++------------ .../runtime/tests/transcript-adapter.spec.ts | 567 ----------- packages/client/test-runtime/src/fixtures.ts | 2 + packages/client/test-runtime/src/index.ts | 8 +- .../ui-conversation/tests/chat-apply.spec.tsx | 10 +- .../tests/chat-branch-tails.spec.tsx | 161 ++-- .../tests/chat-snapshot-fixture.ts | 295 ++++++ .../ui-conversation/tests/chat-stats.spec.tsx | 28 +- .../ui-conversation/tests/chat-view.spec.tsx | 487 ++++------ .../conversation-node-definitions.spec.ts | 720 ++++++++++++++ .../tests/gate-branch-tails.spec.tsx | 20 +- .../ui-conversation/tests/input-bar.spec.tsx | 5 +- .../tests/input-matrix.spec.tsx | 5 +- .../tests/input-scenarios.spec.tsx | 5 +- .../ui-conversation/tests/queue-dock.spec.tsx | 4 +- .../ui-conversation/tests/skeleton.spec.tsx | 5 +- .../tests/produced-files.spec.tsx | 284 ++++-- .../client/ui-slots/tests/surface.spec.ts | 11 + .../client/ui-slots/tests/type-chain.spec.tsx | 60 +- .../ui-tool/tests/assembly-surfaces.spec.tsx | 3 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 10 +- .../client/ui-tool/tests/diff-card.spec.tsx | 7 +- .../client/ui-tool/tests/read-card.spec.tsx | 7 +- .../client/ui-tool/tests/search-card.spec.tsx | 7 +- .../ui-tool/tests/terminal-card.spec.tsx | 7 +- .../ui-tool/tests/tool-call-tree.spec.tsx | 15 +- .../ui-tool/tests/tool-details-render.tsx | 45 +- .../ui-tool/tests/toolview-slot.spec.tsx | 3 +- .../client/ui-tool/tests/web-card.spec.tsx | 7 +- .../web-react/tests/scoped-slots.spec.tsx | 77 +- .../tests/command-compact.spec.ts | 24 +- .../tests/loader-composition.spec.ts | 30 +- .../compact-basic/tests/compact-basic.spec.ts | 8 +- .../tests/manual-compact.spec.ts | 47 +- .../compact/compact/tests/compact.spec.ts | 15 +- .../compact/compact/tests/invariant.spec.ts | 194 +++- .../tests/workspace-context.spec.ts | 6 +- packages/core/tools/tests/code-mode.spec.ts | 5 +- packages/core/tools/tests/invariant.spec.ts | 105 ++- .../agent-spine-demo/tests/agent-core.spec.ts | 1 + .../llm/llm-retry/tests/invariant.spec.ts | 73 +- .../llm/llm-retry/tests/persistence.spec.ts | 2 + packages/llm/llm-retry/tests/retry.spec.ts | 2 + .../context-breakdown-projection.spec.ts | 2 + .../tests/token-usage-projection.spec.ts | 2 + .../tool-cordis/src/api-catalog.ts | 16 +- .../llm-replay/tests/llm-replay.spec.ts | 16 +- pnpm-lock.yaml | 18 +- scripts/gen-cordis-catalog.ts | 1 + 101 files changed, 3970 insertions(+), 2295 deletions(-) delete mode 100644 packages/client/runtime/tests/compact-checkpoint-pin.spec.ts create mode 100644 packages/client/runtime/tests/conversation-assembler.spec.ts create mode 100644 packages/client/runtime/tests/conversation-registry.spec.ts delete mode 100644 packages/client/runtime/tests/transcript-adapter.spec.ts create mode 100644 packages/client/ui-conversation/tests/chat-snapshot-fixture.ts create mode 100644 packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index b7e331c934..f0cc648a10 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 7567ac3cb8b1a580e145f8f0da49b6ec371a35bd -2026-07-19-gui-web-client-architecture.zh.md: 9b682febf1bd1aad1a767b8b2db4e83dd993cf2f +2026-07-19-gui-web-client-architecture.md: 5e927405e9012b5a56d82da8fcf600ca4aa1bd5b +2026-07-19-gui-web-client-architecture.zh.md: 4294af099dce4e1345e625694a054661483f8d12 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 7567ac3cb8..5e927405e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -44,35 +44,35 @@ Implementation homes: registry core and the props-share types in `packages/clien A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: Runtime projects Code Dispatch topology into each root's recursive `subCalls`; ui-conversation places that ordered root into the single `'conversation.chat.tool'` seat without interpreting Tool names or topology; ui-tool renders the supplied tree and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. +There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)). **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). ## The data object layer (`packages/client/runtime/src/client/sessions/`) -Frames enter, snapshots exit, the projection sits between — React-free (zero React imports, grep-assertable): +Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): ``` -mux/host 帧(ConnectionController 泵入,sinks 注入) +mux/host frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope - │ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲) + │ session frames target existing instances (requested waits buffer) ▼ -Session.handleMuxEnvelope ──► events 窗口(seq 连续升序) - │ │ 定稿事件 │ chunk - │ ▼ ▼ - │ TranscriptAdapter PartialAccumulator - │ (→ nodes) (→ partial) +Session.handleMuxEnvelope ──► contiguous Event window + │ │ replace / prepend / append + │ ▼ + │ ConversationNodeAssembler + │ Definitions -> Contexts -> view builders ▼ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件 ``` - **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail. -- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (the human transcript, log-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; an unchanged nodes projection keeps the same array reference, while a changed flow returns a new array that reuses unchanged element references; unchanged substructures reuse the previous snapshot's references. +- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided `useSession(selector)` isolates Node and Location aggregate updates. - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. -- **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. +- **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering. - **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering RFC's territory. ## The React face (`packages/client/web-react`) @@ -95,6 +95,7 @@ src/client/ contract/ shared slot and cross-domain types service.ts cross-domain orchestration skeleton/ conversation shell and details host + conversation-nodes/ independently registered business Definitions and Chat builder chat/ ordered conversation view input/ composer state machine queue/ queued-message presentation @@ -109,13 +110,13 @@ Domain implementation files never import a sibling domain; shared surfaces route - **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically. - **A new slot**: see the [slot system standard RFC](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally. -- **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept. +- **Consuming a new frame type**: transport-only session frames → Session's dispatch switch; host-level frames → the Manager routing table; logged conversation business events → a Definition plus a keyed view renderer, without a Session business branch. - **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)). - **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`. ## Consequences -Token streams no longer shake the render tree: a frame storm costs unsubscribed sessions one dirty bit and the subscribed view one batched re-render per microtask (raf-batched for frame-driven stores). UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer. +Token streams no longer shake the render tree: Assistant chunks update one business Context and publish its keyed Node at most once per animation frame; unrelated rows' selector results retain their references, so those rows do not re-render. UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 9b682febf1..4294af099d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -44,35 +44,35 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:运行时把 Code Dispatch 拓扑投影进每个 root 递归的 `subCalls`;ui-conversation 把这个已排序 root 放进 single `'conversation.chat.tool'` seat,不解释 Tool 名称或拓扑;ui-tool 渲染传入的树,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。 +slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件和 view registry 是数据组装缝,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.md))。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 ## 数据对象层(`packages/client/runtime/src/client/sessions/`) -帧从这里进、快照从这里出、fold 坐在中间——React-free(零 React import,grep 可断言): +帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言): ``` -mux/host 帧(ConnectionController 泵入,sinks 注入) +mux/host frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope - │ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲) + │ session frames target existing instances (requested waits buffer) ▼ -Session.handleMuxEnvelope ──► events 窗口(seq 连续升序) - │ │ 定稿事件 │ chunk - │ ▼ ▼ - │ TranscriptAdapter PartialAccumulator - │ (→ nodes) (→ partial) +Session.handleMuxEnvelope ──► contiguous Event window + │ │ replace / prepend / append + │ ▼ + │ ConversationNodeAssembler + │ Definitions -> Contexts -> view builders ▼ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件 ``` - **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。 -- **ConversationSnapshot**(conversation.ts):不可变快照约定——`nodes`(人类对话记录,日志序)、`partial`、`runningCalls`、`pending`、`running`、`removed`、`openState`、`hasMore`、`promptError` 等。**引用纪律**(memo 与 uSES 的前提):顶层对象每变必新;未变化的 nodes 投影保持同一数组引用,消息流变化时返回新数组并复用未变化的元素引用;未变的子结构复用上一快照的引用。 +- **ConversationSnapshot**(conversation.ts):顶层不可变快照契约。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费者使用的兼容 slice。pending interaction、queue、running、removed、open state、paging 和 prompt error 仍是 Session 信息。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 `useSession(selector)` 隔离 Node 与 Location 聚合更新。 - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 -- **TranscriptAdapter / PartialAccumulator**:对话记录是按日志顺序投影的 append 来源 surface(`@deepseek-ai/dsh-session/surface` 的 `isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface,后者遮蔽被替换的范围,会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。 +- **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)拥有 Tool 递归渲染。 - **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.md)载两个 server→client 象限,客户端类族归分层 RFC 属地。 ## React 面(`packages/client/web-react`) @@ -95,6 +95,7 @@ src/client/ contract/ shared slot and cross-domain types service.ts cross-domain orchestration skeleton/ conversation shell and details host + conversation-nodes/ independently registered business Definitions and Chat builder chat/ ordered conversation view input/ composer state machine queue/ queued-message presentation @@ -108,14 +109,14 @@ src/client/ ## 怎么开发 - **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。 -- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——约定合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。 -- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。 +- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。 +- **消费新帧类型**:纯传输 session frame → Session 分发 switch;host 级 frame → Manager 路由表;已记录的 conversation 业务事件 → Definition 加 keyed view renderer,不增加 Session 业务分支。 - **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。 - **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`。 ## Consequences -token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位,对被订阅视图每微任务一次合批重渲染(帧驱动 store 走 raf 合批)。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。 +token 流不再震荡渲染树:Assistant chunk 只更新一个业务 Context,每 animation frame 最多发布一次对应 keyed Node;无关行的 selector 结果保持原引用,因此不会重渲染。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml index 12e27bf58c..5cb66115e8 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.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-08-08-client-tool-presentation-ownership.md -2026-08-08-client-tool-presentation-ownership.md: f12a116c34064186b6be1496e1071025221817c4 -2026-08-08-client-tool-presentation-ownership.zh.md: f5f7d71f1e05b27855d29acc223b340c8d89f1d8 +2026-08-08-client-tool-presentation-ownership.md: 3feefc3cfbe538024b8610394b9f170c423556e8 +2026-08-08-client-tool-presentation-ownership.zh.md: 031975fdebf5f72a19396f16b086a3abdcbca172 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md index f12a116c34..3feefc3cfb 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -6,100 +6,63 @@ English | [中文](2026-08-08-client-tool-presentation-ownership.zh.md) ## Problem -The Client Runtime already projects Tool calls into a stable lifecycle: it pairs call/result events by `callId`, preserves running and settled forms, and indexes Code Dispatch children by their root call. The chat view nevertheless owned the entire presentation stack. It placed root calls in ChatFlow, composed each root with its subcalls, dispatched every atomic call by Tool name, carried the generic fallback and card models, registered first-party Tool views, and reused those models in the details panel. +Client Runtime already paired Tool call/result events by `callId` and could recover root/subcall topology from Code Dispatch events, but the Chat view also owned Tool placement in the conversation flow, recursive call-tree composition, Tool-name dispatch, the Generic fallback, card models, and first-party Tool renderers. `ui-conversation` therefore had to interpret every business Tool name; moving individual React components did not change that ownership, and removing atomic renderers left subcalls without a presentation owner. -That ownership made `ui-conversation` interpret business Tool names and made subcalls an orphaned concern if an atomic Tool view moved elsewhere. A business package such as `ui-skill` could register a row, but it still depended on conversation's Tool-specific composition contract. Adding Tool-specific Session projection would duplicate a data model the Runtime already owns, while moving only individual React components would leave the composition and model coupling in place. +Tool presentation needed an independent owner without adding a second registry beside Client slots or making every atomic Tool renderer understand root/subcall structure. ## Decision -Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Runtime normalizes Code Dispatch into recursive `ToolCallBlock` values: every root or child owns its next level through `subCalls`, and `ConversationSnapshot` exposes no separate parent-to-children map. +Tool is a first-class Client UI presentation concept. `@deepseek-ai/dsh-client-ui-tool` owns root/subcall composition, atomic renderer dispatch by wire Tool name, the Generic fallback, card models, and details output. Business plugins register only their atomic Tool renderers and do not modify conversation or Session. -“First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool. +Conversation data assembly follows the later [Conversation business-node decision](2026-08-09-client-conversation-node-assembly.md). The `ui-conversation` Tool Definition pairs root call/result Session Events, folds Code Dispatch edges into recursive `ToolCallBlock.subCalls`, and emits one stable `tool-call` Chat Node. This data responsibility handles only official Tool identity and topology; it does not interpret presentation for concrete Tool names. -`ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models. +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists. -`ui-tool` occupies that whole-Tool seat. `ToolCallTree` recursively walks the root block's `subCalls` and routes every level through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. It neither reads Session nor maintains a second call topology. +A business Tool plugin receives one standard `ToolCallBlock`, identity, workspace cwd, and host actions; it does not read Session, Context, or the Conversation assembler. Skill remains an ordinary Tool and uses the same keyed-slot registration path as other business Tools. -Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this slot. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently. - -The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import. - -The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch is an official top-level concept because it changes parent/child identity; a private `ToolCallTree` shares one fold between live and history paths and projects its index into standard recursive call blocks. Ordinary Tool business differences stay at the keyed presentation contract, and this package boundary adds no Tool projector/fold registry. +The details panel is a second Tool presentation point, not the call-tree owner. `ui-conversation` locates the selected call and delegates its output body through `'conversation.details.tool'`; `ui-tool` reuses the card model, while the conversation fallback retains raw result text when the plugin is absent. ## Runtime and render path -This boundary starts at the Client's `ConversationSnapshot`; the full render path is: - ```text -ConversationSnapshot.nodes - -> deriveChatFlow() - -> settled tool-group positions ----+ - | -ConversationSnapshot.runningCalls | - -> ChatView flow tail ---------------+-> ToolSeat - -> conversation.chat.tool - -> ToolCallTree - -> root ToolCallBlock - `- subCalls[] (recursive) - -> tool.call.toolview(entryKey = toolName) - |- registered atomic view - `- GenericToolCard fallback +Session Event window + -> Tool Definition -> tool-call Chat Node (recursive ToolCallBlock) + -> ChatView -> ChatNodeSeat(entryKey = tool-call) + -> ToolCallTree + -> root/subCalls[] recursion + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback ``` -Runtime's [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) privately indexes child lifecycles by parent callId and is shared by the live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) and historical [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) paths. It recursively projects children onto root `ToolCallBlock` values and copies only the owning ancestor path when a child changes. Unchanged siblings, other roots, and snapshot references with no Tool-topology change stay stable so React selectors and memoization can skip unrelated updates. Tool UI consumes this unified tree without repeating call/result pairing, historical replay, or cache indexing. +## Ownership boundary -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. Selection is passed only to the root containing that call, and `ToolCallTree` then renders recursively within that local tree. - -## Code and responsibility boundaries - -| Owner | Primary code | Owns | Explicitly does not own | -|---|---|---|---| -| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, recursive parent/child tree, snapshot structural sharing | Business views selected by Tool name | -| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models | -| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold | -| Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing | -| Details path | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx), [`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected-call lookup, card-aware output, and raw fallback | chat call-tree composition | - -## Slot and owner contract - -A slot declaration also constrains render ownership. The conversation chat entry declares `'conversation.chat.tool'` through `children`, so only `ChatView` places the whole-Tool seat. When `ui-tool` registers that seat, its `children` declares `'tool.call.toolview'`, so only `ToolCallTree` renders the atomic Tool seat. Business plugins register keyed entries only; they neither participate in root/subcall composition nor establish a registry parallel to slots. - -The whole seat's `ToolTreeOwnerProps` carries the root `callId`, `toolName`, `ToolCallBlock`, `selectedCallId`, session `cwd`, `openFile(path)`, and `inspectCall(callId)`. `ToolCallTree` converts either a root or child into the same `ToolCallOwnerProps` and narrows inspect to a callback for that call. The atomic owner carries no `ReactNode`, Cordis `Context`, Session service, or projector; a business view consumes only one standard call block and host actions. - -The seat filler also preserves the conversation DOM contract on every root and child wrapper: `data-chat-anchor-key="call:"`, `data-chat-call-id`, and `data-selected="true"` on the selected call. `ChatView` consumes the anchor key to restore prepend/paging position; the Tool owner emits it because it alone composes child wrappers. - -Business plugins use one registration shape: - -```text -ctx.slots.inject('tool.call.toolview', () => - ctx.slots.register({ - name: 'tool.call.toolview', - key: '', - }, BusinessToolRow)) -``` - -`ui-tool`'s [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the whole-Tool renderer, details renderer, and existing built-in atomic views. An existing independent business package can move only its keyed registration, as `ui-skill` does, without changing `ui-conversation` or Session. - -## Details path - -[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) locates the selected call recursively in `nodes` and `runningCalls` through their `subCalls`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse. +| Owner | Owns | Explicitly does not own | +|---|---|---| +| Client Runtime Conversation engine | Context identity, Location, history replay, view Node publication | Tool event meaning, call tree, Tool renderer | +| `ui-conversation` Tool Definition | call/result pairing, Code Dispatch topology, running/settled/interrupted `ToolCallBlock`, Chat ordering anchor | Tool-name dispatch, card models, recursive React structure | +| `ui-conversation` Chat view | keyed Node order, scroll anchors, selection, and host actions | Tool lifecycle, subcall composition, atomic Tool renderers | +| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, and details output | Session Event fold, Chat ordering | +| Business Tool plugin | atomic renderers for one or more wire Tool names | root/subcall placement, lifecycle pairing, Session projectors | ## Verification -Test ownership follows production ownership. `ui-conversation` tests install a local whole-Tool seat probe and assert only ChatFlow placement, owner payload, and host contracts such as selection, open-file, and inspect; they do not import `ui-tool` production code or test helpers. `ui-tool` tests mount a real conversation host and verify root/subcall composition, keyed dispatch, generic fallback, concrete Tool UI, and plugin lifecycle. +`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, and concrete Tool cards. Assembled Web tests cover the path with both plugins loaded. ## Alternatives considered -**Keep atomic Tool slots under every conversation view.** Rejected: each view would have to reproduce root/subcall composition, and a Tool registration would be isolated by view even though its business meaning is Tool-wide. A whole-Tool seat preserves view-owned placement while giving the call tree one owner. This supersedes the per-view placement selected by the earlier [toolview dissolution](2026-07-23-toolview-dissolution.md), while retaining its keyed-slot and no-parallel-registry decisions. +**Keep atomic Tool slots under every conversation view.** Rejected: every view would repeat root/subcall composition and Tool registration would split by view. The whole Tool renderer occupies one business Node slot in a view, while Tool owns atomic dispatch. -**Move only the Tool React components and card models.** Rejected: `ChatView` would still own Tool-name dispatch and Code Dispatch composition, so the dependency would change file paths without changing responsibility. +**Move only Tool React components and card models.** Rejected: conversation would still dispatch by Tool name and recurse through subcalls, so file movement would not create an ownership boundary. -**Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension. +**Create a Tool-specific projector/fold registry.** Rejected: the general Conversation assembler already owns Context identity, history windows, and publication. A second Runtime registry would create two lifecycle authorities. -**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Recursive root/child composition belongs centrally to `ui-tool`'s `ToolCallTree`. +**Let every atomic Tool renderer recurse through its subcalls.** Rejected: an atomic registrant should understand one Tool call without knowing whether it is a root or child. `ToolCallTree` handles recursive structure once. -**Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading. +**Let `ui-conversation` import `ui-tool` components directly.** Rejected: this would reverse the feature dependency and make Tool presentation mandatory. Slots preserve independent loading, lifecycle, and fallback behavior. ## Consequences -`ui-conversation` becomes independent of Tool-name business presentation while retaining ChatFlow, selection, and host interaction responsibilities. Root calls and subcalls cannot drift onto different dispatch paths, and business packages can own atomic Tool presentation without Session changes. The cost is one new Client package and two cross-package slot contracts; `ui-tool` also deliberately depends on conversation's declared seats and locale namespace. The assembled Web bundle therefore mounts `ui-tool`; omitting it leaves chat Tool seats empty while the details seat keeps its raw-result fallback, without changing Session reconstruction. +`ui-conversation` no longer depends on presentation for concrete Tool names, and root and subcalls cannot drift onto different dispatch paths. Business packages can independently own atomic Tool renderers; if `ui-tool` is absent, Conversation data assembly remains valid, Chat Nodes use the generic fallback, and details retain raw results. + +The cost is an explicit dependency from `ui-tool` on the business Node slot and locale namespace declared by conversation, plus one Tool-specific child slot. Tool Definition remains in `ui-conversation` because this change does not split packages; it can later move through the Conversation registry seam without changing the presentation ownership recorded here. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md index f5f7d71f1e..031975fdeb 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -6,100 +6,63 @@ Status: implemented ## Problem -Client Runtime 已经把 Tool 调用投影成稳定的生命周期:它按 `callId` 配对 call/result 事件,保留 running 与 settled 两种形态,并按 root call 索引 Code Dispatch 子调用。但 chat view 仍拥有整套展示链路:它在 ChatFlow 中放置 root call,把每个 root 与 subcall 编排在一起,按 Tool 名称分发每个原子调用,携带通用 fallback 与 card model,注册第一方 Tool view,并在 details panel 中复用这些 model。 +Client Runtime 已经按 `callId` 配对 Tool call/result,并能从 Code Dispatch 事件恢复 root/subcall 拓扑,但 Chat view 曾同时拥有 Tool 在对话流中的放置、递归调用树、按 Tool 名称分发、Generic fallback、card model 和第一方 Tool renderer。`ui-conversation` 因此必须解释每个业务 Tool 名称;只移动单个 React 组件不会改变这层所有权,移走原子 renderer 后 subcall 也会成为无主逻辑。 -这种所有权迫使 `ui-conversation` 解释业务 Tool 名称;一旦原子 Tool view 被迁走,subcall 就会成为无主的遗留关注点。`ui-skill` 等业务包虽能注册一行视图,仍依赖 conversation 的 Tool 专属编排约定。增加 Tool 专属 Session projection 会重复 Runtime 已拥有的数据模型,而只移动单个 React 组件则会把编排与 model 耦合留在原地。 +Tool presentation 需要一个独立所有者,同时不能建立与 Client slot 平行的第二套注册表,也不能让每个原子 Tool renderer 自己理解 root/subcall 结构。 ## Decision -Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Runtime 将 Code Dispatch 规范化为递归 `ToolCallBlock`:每个 root 或 child 通过自己的 `subCalls` 拥有下一层调用,`ConversationSnapshot` 不再公开单独的 parent-to-children map。 +Tool 是 Client UI 的一级展示概念,由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有 root/subcall 编排、按 wire Tool name 的原子 renderer 分发、Generic fallback、card model 和 details output。业务插件只注册自己的原子 Tool renderer,不修改 conversation 或 Session。 -这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection,`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool。 +Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-08-09-client-conversation-node-assembly.md)。`ui-conversation` 的 Tool Definition 从 Session Event 配对 root call/result,把 Code Dispatch edge fold 成递归 `ToolCallBlock.subCalls`,并生成一个稳定的 `tool-call` Chat Node;这里的数据职责只处理官方 Tool identity 和拓扑,不解释具体 Tool 名称的展示。 -`ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session 的 `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model。 +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只按 Chat snapshot 的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。 -`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 直接递归遍历 root block 的 `subCalls`,并让每一层调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。它不读取 Session,也不维护第二份调用拓扑。 +业务 Tool 插件接收一个标准 `ToolCallBlock`、identity、workspace cwd 和宿主动作,不读取 Session、Context 或 Conversation assembler。Skill 仍是普通 Tool;它和其他业务 Tool 使用同一 keyed slot 注册路径。 -业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool,`ui-skill` 通过该 slot 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它。 +details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 定位 selected call,并通过 `'conversation.details.tool'` 委托 output body;`ui-tool` 复用 card model,插件缺席时 conversation fallback 保留 raw result text。 -details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body;`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。 - -Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 作为官方顶级概念改变 parent/child identity;私有 `ToolCallTree` 对 live 与 history 共用同一套 fold,并把索引投影成标准递归 call block。普通 Tool 业务差异停留在 keyed 展示约定,这个包边界不会增加 Tool projector/fold registry。 - -## Runtime 与渲染链路 - -这项边界从 Client 的 `ConversationSnapshot` 开始,完整渲染链路如下: +## Runtime and render path ```text -ConversationSnapshot.nodes - -> deriveChatFlow() - -> settled tool-group positions ----+ - | -ConversationSnapshot.runningCalls | - -> ChatView flow tail ---------------+-> ToolSeat - -> conversation.chat.tool - -> ToolCallTree - -> root ToolCallBlock - `- subCalls[] (recursive) - -> tool.call.toolview(entryKey = toolName) - |- registered atomic view - `- GenericToolCard fallback +Session Event window + -> Tool Definition -> tool-call Chat Node (recursive ToolCallBlock) + -> ChatView -> ChatNodeSeat(entryKey = tool-call) + -> ToolCallTree + -> root/subCalls[] recursion + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback ``` -Runtime 的 [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) 私下按 parent callId 索引 child lifecycle,并供 Live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 与历史 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 共用。它把 children 递归投影到 root `ToolCallBlock`,child 变化时只复制所属祖先路径;未变化的 sibling、其他 root,以及没有 Tool 拓扑变化的 snapshot 引用保持稳定,供 React selector 与 memo 跳过无关更新。Tool UI 直接消费这两个路径统一后的树,不重复 call/result 配对、历史 replay 或缓存索引。 +## Ownership boundary -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。selection 只传给包含该 call 的 root,`ToolCallTree` 再沿该 root 的局部树递归渲染。 - -## 代码与职责边界 - -| 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 | -|---|---|---|---| -| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、递归 parent/child 树、snapshot 结构共享 | Tool 名称对应的业务视图 | -| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model | -| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold | -| 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 | -| details 路径 | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx)、[`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected call 定位、card-aware output 与 raw fallback | chat 调用树编排 | - -## Slot 与 owner 约定 - -slot 声明同时限定渲染所有权。conversation chat entry 通过 `children` 声明 `'conversation.chat.tool'`,因此只有 `ChatView` 放置整体 Tool 席位;`ui-tool` 注册该席位时再通过 `children` 声明 `'tool.call.toolview'`,因此只有 `ToolCallTree` 渲染原子 Tool 席位。业务插件只注册 keyed entry,不参与 root/subcall 编排,也不建立与 slot 平行的 registry。 - -整体席位的 `ToolTreeOwnerProps` 携带 root `callId`、`toolName`、`ToolCallBlock`、`selectedCallId`、session `cwd`、`openFile(path)` 与 `inspectCall(callId)`。`ToolCallTree` 把 root 或 child 转成相同的 `ToolCallOwnerProps`,并把 inspect 收窄成当前 call 的回调。原子 owner 不携带 `ReactNode`、Cordis `Context`、Session service 或 projector;业务 view 只消费一个标准调用块和宿主动作。 - -席位填充方还要在每个 root 和 child wrapper 上保留 conversation DOM 约定:`data-chat-anchor-key="call:"`、`data-chat-call-id`,以及 selected call 上的 `data-selected="true"`。`ChatView` 用 anchor key 恢复 prepend/paging 位置;child wrapper 由 Tool owner 独自编排,因此这些属性也由它输出。 - -业务插件遵循同一个注册形态: - -```text -ctx.slots.inject('tool.call.toolview', () => - ctx.slots.register({ - name: 'tool.call.toolview', - key: '', - }, BusinessToolRow)) -``` - -`ui-tool` 的 [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册整体 Tool renderer、details renderer 与现有内置原子 view;已有独立业务包可以像 `ui-skill` 一样只迁走自己的 keyed 注册,无需改动 `ui-conversation` 或 Session。 - -## Details 路径 - -[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 在 `nodes` 与 `runningCalls` 的递归 `subCalls` 中定位 selected call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。 +| 所有者 | 拥有 | 明确不拥有 | +|---|---|---| +| Client Runtime Conversation engine | Context identity、Location、历史重放、view Node 发布 | Tool 事件含义、调用树、Tool renderer | +| `ui-conversation` Tool Definition | call/result 配对、Code Dispatch 拓扑、running/settled/interrupted `ToolCallBlock`、Chat 排序 anchor | Tool 名称分发、card model、递归 React 结构 | +| `ui-conversation` Chat view | keyed Node 顺序、scroll anchor、selection 与宿主动作 | Tool lifecycle、subcall 组合、原子 Tool renderer | +| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model 与 details output | Session Event fold、Chat 排序 | +| 业务 Tool 插件 | 一个或多个 wire Tool name 的原子 renderer | root/subcall 位置、生命周期配对、Session projector | ## Verification -测试归属跟随生产所有权。`ui-conversation` 的测试安装本地整体 Tool 席位替身,只验证 ChatFlow 位置、owner payload 与 selection、open-file、inspect 等宿主约定;它们不导入 `ui-tool` 的生产实现或测试 helper。`ui-tool` 的测试挂载真实 conversation 宿主,验证 root/subcall 编排、keyed dispatch、generic fallback、具体 Tool UI 与插件生命周期。 +`ui-conversation` 测试固定 Tool Definition 的 call/result、Code Dispatch、interruption 和 running-to-settled keyed identity,不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details 和具体 Tool card。组装后的 Web 测试覆盖两侧插件共同装载的路径。 ## Alternatives considered -**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都必须重复 root/subcall 编排,而且 Tool 注册会按 view 隔离,即使它的业务语义本应是 Tool 级。整体 Tool 席位保留 view 对放置位置的所有权,同时让调用树只有一个所有者。它取代了早期 [toolview 溶解](2026-07-23-toolview-dissolution.md)所选择的 per-view 放置方式,但保留 keyed slot 与不设平行 registry 的决策。 +**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都要重复 root/subcall 编排,Tool 注册也会按 view 分裂。整个 Tool renderer 占据 view 的一个业务 Node slot,原子分发由 Tool 自己拥有。 -**只移动 Tool React 组件与 card model。** 拒绝:`ChatView` 仍会拥有 Tool 名称分发与 Code Dispatch 编排,只是改变文件路径,没有改变责任。 +**只移动 Tool React 组件与 card model。** 拒绝:conversation 仍会按 Tool 名称分发并递归 subcall,文件位置变化不产生所有权边界。 -**增加业务专属 Session projector 或 fold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展。 +**为 Tool 建立专属 projector/fold registry。** 拒绝:通用 Conversation assembler 已拥有 Context identity、历史窗口和发布;第二个 Runtime registry 会制造生命周期的双重权威。 -**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。递归 root/child 编排统一归 `ui-tool` 的 `ToolCallTree`。 +**让每个原子 Tool renderer 递归自己的 subcall。** 拒绝:原子注册方只应理解一个 Tool call,不应知道自己是 root 还是 child。递归结构统一由 `ToolCallTree` 处理。 -**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 能保留生命周期所有权、fallback 行为与独立插件装载。 +**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转 feature 依赖并把 Tool 展示变成必选能力。slot 保留独立装载、生命周期和 fallback。 ## Consequences -`ui-conversation` 不再依赖 Tool 名称对应的业务展示,同时保留 ChatFlow、selection 与宿主交互责任。root call 与 subcall 不会漂移到不同分发路径,业务包无需修改 Session 即可拥有原子 Tool 展示。代价是新增一个 Client package 与两份跨包 slot 约定;`ui-tool` 也明确依赖 conversation 声明的席位与 locale namespace。因此组装后的 Web bundle 会挂载 `ui-tool`;省略该插件时,chat Tool 席位为空,details 席位则保留 raw-result fallback,且 Session 重建不受影响。 +`ui-conversation` 不再依赖 Tool 名称对应的业务展示,root 与 subcall 也不会漂移到不同分发路径。业务包可以独立拥有原子 Tool renderer;`ui-tool` 缺席时,Conversation 数据组装仍然成立,Chat Node 使用通用 fallback,details 保留 raw result。 + +代价是 `ui-tool` 明确依赖 conversation 声明的业务 Node slot 和 locale namespace,并拥有一个 Tool 专属子 slot。Tool Definition 暂时位于 `ui-conversation`,因为本次没有拆 package;它以后可以沿 Conversation registry seam 移动,而不会改变本 Note 规定的展示所有权。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 231502dcb7..1ea341085d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.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/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: e261ccba597f27149a9527f99b93454dfe5fe5fb -2026-07-30-web-transcript-log-ordered-projection.zh.md: 5684a8157bb7e1b23cd868fa13ab201ffd07a274 +2026-07-30-web-transcript-log-ordered-projection.md: feefc951118c8511237c931a13070e1f7db1fd16 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 711e57b29742171d1861f25e63019b64356b4cad diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index e261ccba59..feefc95111 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. -`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it. +`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 5684a8157b..711e57b297 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' 重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 -`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。该测试以值导入方式从不含 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 叶子路径取得该值,并刻意不加载 compact 包根或经由它可达的宿主侧 `Context` 合并。 +`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 80661a0b13..be719bda15 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -38,7 +38,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) - // Resolve the resident approval so the ordinary composer bar (which owns // ContextMeter) resumes without replacing the session shell. This minimal // boot graph intentionally does not mount the separate question UI plugin. diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 61701c1cdc..a7b659dab2 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' import { launchWebScaffold, watchConsole, @@ -160,6 +161,14 @@ function toolResultText(event: Extract): .join('') } +function messageKey(event: SessionEvent<'user/message'>): string { + return conversationContextKey('input-message', String(event.data.id)) +} + +function assistantKey(event: SessionEvent<'assistant/message'>): string { + return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`) +} + describe('web e2e: continuous conversation grown through the composer', () => { let browser: Browser let page: Page @@ -222,6 +231,11 @@ describe('web e2e: continuous conversation grown through the composer', () => { const settled = scaffold.whenTurnSettled(60_000) await page.getByRole('button', { name: 'Send message', exact: true }).click() await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + await expect.poll(() => sessionEvents.slice(eventStart).some(event => ( + event.type === 'user/message' + && event.data.source.kind === 'user' + && userText(event).includes(spec.userMarker) + )), { timeout: 15_000 }).toBe(true) const echoedUser = sessionEvents.slice(eventStart).find( (event): event is SessionEvent<'user/message'> => ( event.type === 'user/message' @@ -230,7 +244,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { ), ) if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`) - const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`) + const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`) await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1) expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user') expect(await userRow.textContent()).toContain(spec.userMarker) @@ -274,9 +288,9 @@ describe('web e2e: continuous conversation grown through the composer', () => { expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } }) expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9)) - const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`) + const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`) await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1) - expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step') expect(await assistantRow.textContent()).toContain(spec.doneMarker) const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call') diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index b9bebbd897..cd1fcb2fa7 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' import { createChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, @@ -115,6 +116,18 @@ function requiredEvent( return event } +function messageKey(event: SessionEvent<'user/message'>): string { + return conversationContextKey('input-message', String(event.data.id)) +} + +function assistantKey(event: SessionEvent<'assistant/message'>): string { + return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`) +} + +function turnTailKey(turn: number): string { + return conversationContextKey('turn-tail', String(turn)) +} + describe('web e2e: long Chat interaction contract', () => { let browser: Browser let page: Page @@ -176,8 +189,10 @@ describe('web e2e: long Chat interaction contract', () => { const expectedUserText = textContent(branchUserEvent.data.content) await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100) - const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`) - const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`) + const toolUserKey = messageKey(toolUserEvent) + const toolAssistantKey = assistantKey(toolAssistantEvent) + const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`) + const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`) const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`) const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`) @@ -186,28 +201,27 @@ describe('web e2e: long Chat interaction contract', () => { expect(await call1.count()).toBe(1) expect(await call2.count()).toBe(1) expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user') - expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step') expect(await toolUserRow.textContent()).toContain(toolUserMarker) expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker) expect(await call1.textContent()).toContain(toolMarker1) expect(await call2.textContent()).toContain(toolMarker2) const expectedOrder = [ - `node:${String(toolUserEvent.seq)}`, - `call:${TARGET_CALL_1}`, - `call:${TARGET_CALL_2}`, - `node:${String(toolAssistantEvent.seq)}`, + toolUserKey, + conversationContextKey('tool-call', TARGET_CALL_1), + conversationContextKey('tool-call', TARGET_CALL_2), + toolAssistantKey, ] const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => ( rows.map(row => (row as HTMLElement).dataset.chatAnchorKey) .filter((key): key is string => key !== undefined && keys.includes(key)) ), expectedOrder) expect(actualOrder).toEqual(expectedOrder) - const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => ( - element.closest('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null + const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => ( + element.closest('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null )))) - expect(groupKeys[0]).not.toBeNull() - expect(groupKeys[1]).toBe(groupKeys[0]) + expect(toolKinds).toEqual(['tool-call', 'tool-call']) const summary1 = call1.locator('[data-sample="bash"]') const summary2 = call2.locator('[data-sample="bash"]') @@ -219,9 +233,12 @@ describe('web e2e: long Chat interaction contract', () => { expect(await summary1.getAttribute('aria-expanded')).toBe('false') await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 }) - await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100) - const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`) - const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`) + const branchUserKey = messageKey(branchUserEvent) + const branchAssistantKey = assistantKey(branchAssistantEvent) + await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100) + const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`) + const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`) + const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`) expect(await userRow.textContent()).toContain(branchUserMarker) expect(await assistantRow.textContent()).toContain(branchAssistantMarker) await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) @@ -230,8 +247,8 @@ describe('web e2e: long Chat interaction contract', () => { await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 }) .toBe(expectedUserText) - await assistantRow.hover() - await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click() + await turnTailRow.hover() + await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)), { timeout: 15_000 }, diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 8e6322c4af..8ac775be1d 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -2,7 +2,7 @@ // Assembled search-card snapshot: boots the real built workspace client bundles // through AppWebEntry's ModuleLoader path against the keyless // FixtureApiClient transport (no API key, no model round), opens the fixture -// session, and pins the search card the `grep` turn (fixture turn 66) renders in +// session, and pins the search card the `grep` turn (fixture turn 67) renders in // the assembled application. The built-boot smoke proves the graph boots but // carries no behavior assertions by contract; this is the assembled-output check // that a broken SearchRow registration or a dropped card would fail — the @@ -51,7 +51,7 @@ describe('assembled search card', () => { const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) fireEvent.click(await within(tree).findByText('Fixture 历史会话')) // Wait for chat content to reach the fixture's later turns (the bash sample - // is turn 65, the grep card turn 66). + // is turn 66, the grep card turn 67). await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 072a322fb6..8a9ee26866 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -91,11 +91,15 @@ function withCompaction(raw: string, meter: TokenMeterService): string { return taken } const commandId = 'cmd-seeded-manual-compact' + const compactionId = 'compact-seeded-manual-compact' at({ type: 'command/run', data: { commandId, name: 'compact', args: '', source: { kind: 'user' } }, }) - const startSeq = at({ type: 'compact/start', data: { turn: null } }) + const startSeq = at({ + type: 'compact/start', + data: { compactionId, sourceCommandId: commandId, turn: null }, + }) // Load-bearing exactness: the projections subtract this count verbatim, so // it must equal what the host's fold prices for these nodes. The estimator // prices message CONTENT only, so a minimal wrapper per storage shape is @@ -124,6 +128,8 @@ function withCompaction(raw: string, meter: TokenMeterService): string { const summarySeq = at({ type: 'compact/summary', data: { + compactionId, + sourceCommandId: commandId, summary: [{ type: 'text', text: '## Cold resume compact summary\n\n- The exact summary remains available.', @@ -142,12 +148,17 @@ function withCompaction(raw: string, meter: TokenMeterService): string { type: 'text', text: 'Model-only compact checkpoint.', }], - source: { kind: 'plugin', plugin: 'compact' }, + source: { + kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId, + }, }, surfaceOp: { op: 'replace', start: first, end: last }, sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], }) - at({ type: 'compact/end', data: { turn: null } }) + at({ + type: 'compact/end', + data: { compactionId, sourceCommandId: commandId, turn: null }, + }) at({ type: 'command/done', data: { diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index 302aa062b0..8555ef7499 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -109,6 +109,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro { timeout: 10_000 }, ).toBe(1) + const settled = scaffold.whenTurnSettled() await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) await composer.press('Enter') @@ -135,6 +136,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro // The injection started a turn; the replay adapter answers it. await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + await settled const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/code-mode-round/session.jsonl b/apps/web/tests/snapshots/code-mode-round/session.jsonl index 9ff7af0110..09e2432e94 100644 --- a/apps/web/tests/snapshots/code-mode-round/session.jsonl +++ b/apps/web/tests/snapshots/code-mode-round/session.jsonl @@ -14,10 +14,10 @@ {"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} {"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}} -{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}} -{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}} -{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}} -{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}} +{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}} +{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}} +{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}} +{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}} {"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"} {"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}} {"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}} diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index c05057dddc..c5ded143b3 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -2,7 +2,7 @@ // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the // keyless FixtureApiClient transport, opens the fixture session, and pins the -// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`) +// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // // The row is pinned as three separate fields on purpose. `summary=` is the diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5f3bc744b2..22e21edc0b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -917,7 +917,7 @@ Requires: `agents` export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:46`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:33`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` @@ -2325,7 +2325,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:616`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:623`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5b36725402..a8263b70c1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:174`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index e7d6b47e70..767ebfcfde 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -436,9 +436,6 @@ flowchart TD pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill - pkg_compact --> pkg_invariants - pkg_compact --> pkg_llm - pkg_compact --> pkg_session pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web @@ -502,15 +499,11 @@ flowchart TD pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout - pkg_token_meter --> pkg_compact - pkg_token_meter --> pkg_invariants - pkg_token_meter --> pkg_llm - pkg_token_meter --> pkg_session - pkg_token_meter --> pkg_session_projection pkg_agent_default_model --> pkg_agent pkg_agent_default_model --> pkg_invariants pkg_agent_default_model --> pkg_llm @@ -550,10 +543,6 @@ flowchart TD pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session - pkg_llm_replay --> pkg_compact - pkg_llm_replay --> pkg_invariants - pkg_llm_replay --> pkg_llm - pkg_llm_replay --> pkg_session pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm @@ -674,14 +663,11 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_command_compact --> pkg_commands - pkg_command_compact --> pkg_compact - pkg_command_compact --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_compact - pkg_compact_tool_result_prune --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_llm - pkg_compact_tool_result_prune --> pkg_session - pkg_compact_tool_result_prune --> pkg_token_meter + pkg_compact --> pkg_brand + pkg_compact --> pkg_commands + pkg_compact --> pkg_invariants + pkg_compact --> pkg_llm + pkg_compact --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -703,13 +689,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -742,6 +721,11 @@ flowchart TD pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout + pkg_token_meter --> pkg_compact + pkg_token_meter --> pkg_invariants + pkg_token_meter --> pkg_llm + pkg_token_meter --> pkg_session + pkg_token_meter --> pkg_session_projection pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -790,13 +774,9 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_compact_tool_result_prune - pkg_compact_basic --> pkg_invariants - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session - pkg_compact_basic --> pkg_token_meter + pkg_command_compact --> pkg_commands + pkg_command_compact --> pkg_compact + pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants @@ -857,33 +837,10 @@ flowchart TD pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_tool --> pkg_client_locale - pkg_client_ui_tool --> pkg_client_runtime - pkg_client_ui_tool --> pkg_client_ui_conversation - pkg_client_ui_tool --> pkg_client_ui_primitives - pkg_client_ui_tool --> pkg_client_ui_slots - pkg_client_ui_tool --> pkg_invariants + pkg_llm_replay --> pkg_compact + pkg_llm_replay --> pkg_invariants + pkg_llm_replay --> pkg_llm + pkg_llm_replay --> pkg_session pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -990,6 +947,11 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_compact_tool_result_prune --> pkg_compact + pkg_compact_tool_result_prune --> pkg_invariants + pkg_compact_tool_result_prune --> pkg_llm + pkg_compact_tool_result_prune --> pkg_session + pkg_compact_tool_result_prune --> pkg_token_meter pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1038,50 +1000,14 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_client_ui_tool - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_compact + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_token_meter pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1105,6 +1031,14 @@ flowchart TD pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_commands + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_compact_tool_result_prune + pkg_compact_basic --> pkg_invariants + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session + pkg_compact_basic --> pkg_token_meter pkg_subagent_codex --> pkg_invariants pkg_subagent_codex --> pkg_llm pkg_subagent_codex --> pkg_sdk_protocol @@ -1120,6 +1054,50 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_runtime + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_primitives + pkg_client_ui_tool --> pkg_client_ui_slots + pkg_client_ui_tool --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1161,6 +1139,33 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1238,7 +1243,6 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | -| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | @@ -1255,8 +1259,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1267,7 +1270,6 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1295,19 +1297,18 @@ flowchart TD | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | @@ -1315,7 +1316,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | @@ -1325,10 +1326,7 @@ flowchart TD | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1347,6 +1345,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1355,20 +1354,26 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/scaffold/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 614f6afbeb..76487ca3d4 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -212,7 +212,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/index.ts:151`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:153`](../packages/interaction/commands/src/index.ts) #### `command/run` — log-only @@ -230,7 +230,7 @@ Source: [`packages/interaction/commands/src/index.ts:151`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/index.ts:144`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:146`](../packages/interaction/commands/src/index.ts) ### `compact/*` @@ -241,10 +241,10 @@ Source: [`packages/interaction/commands/src/index.ts:144`](../packages/interacti * Marks the end of a compaction — log-only, releases the lock. Its owner * matches `compact/start`; `error` records an unsuccessful attempt. */ -'compact/end': { turn: number | null; error?: string } +'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:69`](../packages/compact/compact/src/types.ts) #### `compact/prune` — log-only @@ -268,7 +268,7 @@ Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact } ``` -Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:79`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -278,10 +278,10 @@ Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact * `compact/end`. A numbered owner is strictly enclosed by that open turn; * `null` identifies a standalone manual transaction between turns. */ -'compact/start': { turn: number | null } +'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } ``` -Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:21`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -296,6 +296,8 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { + compactionId: CompactionId + sourceCommandId?: CommandId summary: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] @@ -331,7 +333,7 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:31`](../packages/compact/compact/src/types.ts) ### `feedback/*` @@ -412,29 +414,19 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- ```ts persistence-catalog /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ -'llm/retry': { - turn: number - step: number - provider: string - mode: 'normal' - policyKey: string - retry: number - maxRetries: number - delayMs: number - failure: LlmFailure -} | { - turn: number - step: number - provider: string - mode: 'always' - policyKey: string - retry: number - delayMs: number - failure: LlmFailure -} +'llm/retry': LlmRetryEventData ``` -Source: [`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:20`](../packages/llm/llm-retry/src/index.ts) + +#### `llm/retry-started` — log-only + +```ts persistence-catalog +/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ +'llm/retry-started': LlmRetryStartedEventData +``` + +Source: [`packages/llm/llm-retry/src/index.ts:22`](../packages/llm/llm-retry/src/index.ts) ### `permission/*` @@ -656,7 +648,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } +'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } ``` Types: [CallId](subsystems/core.md) · [ContentBlock](subsystems/core.md) @@ -679,7 +671,7 @@ Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/c * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } +'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } ``` Types: [CallId](subsystems/core.md) diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 5bc05d2916..9466165680 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.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/commands.md -commands.md: c71c19b8a3f94ba468e6871d5658c4eca1c6276d -commands.zh.md: 3f1df0e8aa40524160c8f770793b6064dd5988ae +commands.md: 7f4e4e87d7206be3e46c7076ee5e8ab6a7589f9a +commands.zh.md: 0f8ff31bca5f62824912f53aee8dbc32db4c331b diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index c71c19b8a3..7f4e4e87d7 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -49,6 +49,8 @@ The adapter owns cancellation and passes the exact target agent. `rawInput` begi ```ts type-equiv /** Invocation passed to one registered command handler. */ interface CommandInvocation { + /** Pairing id already written to this invocation's `command/run` event. */ + readonly commandId: CommandId /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ @@ -159,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -181,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:172`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 3f1df0e8aa..0f8ff31bca 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -49,6 +49,8 @@ interface CommandDefinition { ```ts type-equiv /** Invocation passed to one registered command handler. */ interface CommandInvocation { + /** Pairing id already written to this invocation's `command/run` event. */ + readonly commandId: CommandId /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ @@ -159,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -181,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:172`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index 6445fa5d5c..fd8dbdd1b5 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.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/compaction.md -compaction.md: 942cc740db6b7006952bfc33a39ea9826d673e61 -compaction.zh.md: c5b65a95f9c0db8b02912c68ef93edce2ceb7c43 +compaction.md: 1aca48d677a83a488d9590edad3f8a68ca662b70 +compaction.zh.md: e2cfabc0891126d9f2165e47dffcf485f4a8a339 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 942cc740db..1aca48d677 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -29,6 +29,10 @@ What a successful compaction returns to its caller: the bookkeeping-event seqs, ```ts type-equiv /** Result of a successful compaction operation. */ interface CompactionResult { + /** Stable identity shared by this compaction's complete durable lifecycle. */ + compactionId: CompactionId + /** Human command that initiated this compaction, when it was manual. */ + sourceCommandId?: CommandId /** The seq of the appended `compact/start` event. */ startSeq: number /** The seq of the appended `compact/summary` event. */ @@ -155,13 +159,14 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger * * @param agent - idle agent whose durable history should be compacted. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for a manual compaction. * @returns the compaction result, or `null` when no safe useful range exists. * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, * changed-span, summarization/shrink, commit-stage, or persistence failures; * an aborted request preserves its exact abort reason. Failed attempts remain * visible in the log. */ -abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise +abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise /** * Forcibly compact a range of surface nodes into a single summary node. @@ -184,7 +189,9 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Source: [`packages/compact/compact/src/index.ts:93`](../../packages/compact/compact/src/index.ts) +Types: [CommandId](commands.md) + +Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts) diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index c5b65a95f9..e2cfabc089 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -29,6 +29,10 @@ ```ts type-equiv /** Result of a successful compaction operation. */ interface CompactionResult { + /** Stable identity shared by this compaction's complete durable lifecycle. */ + compactionId: CompactionId + /** Human command that initiated this compaction, when it was manual. */ + sourceCommandId?: CommandId /** The seq of the appended `compact/start` event. */ startSeq: number /** The seq of the appended `compact/summary` event. */ @@ -155,13 +159,14 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger * * @param agent - idle agent whose durable history should be compacted. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for a manual compaction. * @returns the compaction result, or `null` when no safe useful range exists. * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, * changed-span, summarization/shrink, commit-stage, or persistence failures; * an aborted request preserves its exact abort reason. Failed attempts remain * visible in the log. */ -abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise +abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise /** * Forcibly compact a range of surface nodes into a single summary node. @@ -184,7 +189,9 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Source: [`packages/compact/compact/src/index.ts:93`](../../packages/compact/compact/src/index.ts) +Types: [CommandId](commands.md) + +Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 42f73824cf..d015cdb638 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.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/tools.md -tools.md: 87910dfbb86e9db0f0545bc2fe1135c4aefb2e69 -tools.zh.md: d312090b722de3fd1b8efc7703eafdf7f3c38682 +tools.md: f06db58b9b52fcb2c325ff813406cd2dc460ea7b +tools.zh.md: cca4bf9faa870d9414d95f785b67e0393e469cdd diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 87910dfbb8..f06db58b9b 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -184,6 +184,11 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } */ interface ToolExecutionInput { readonly callId: CallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: CallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -280,6 +285,8 @@ interface CodeDispatchLog { * observers run. */ interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: CallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } @@ -547,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index d312090b72..cca4bf9faa 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -184,6 +184,11 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } */ interface ToolExecutionInput { readonly callId: CallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: CallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -280,6 +285,8 @@ interface CodeDispatchLog { * observers run. */ interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: CallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } @@ -547,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index f4169abe78..d6935b6c98 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -25,8 +25,8 @@ {"type":"assistant/chunk","seq":23,"time":1785730458465,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":24,"time":1785730458465,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e3061430-3f2d-4dd8-a3ee-c0fde800547d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785730458465,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":26,"time":1785730458517,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":27,"time":1785730458518,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/code-dispatch-start","seq":26,"time":1785730458517,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":27,"time":1785730458518,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} {"type":"tool/result","seq":28,"time":1785730458520,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"4dce223d-0097-4ac2-a717-d1c430240cef"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1785730458520,"data":{"turn":1,"step":2}} {"type":"step/start","seq":30,"time":1785730458527,"data":{"turn":1,"step":3}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index a1b26a5af4..7490f9487f 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -18,8 +18,8 @@ {"type":"assistant/chunk","seq":103,"time":1785730479356,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":104,"time":1785730479356,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."},{"type":"tool-call","id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1cf31c-fd73-42fc-805d-a14d91228bd9"},"usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103],"surfaceOp":"append"} {"type":"tool/call","seq":105,"time":1785730479356,"data":{"turn":1,"step":1,"callId":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}} -{"type":"tool/code-dispatch-start","seq":106,"time":1785730479411,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}} -{"type":"tool/code-dispatch","seq":107,"time":1785730479421,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}} +{"type":"tool/code-dispatch-start","seq":106,"time":1785730479411,"data":{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}} +{"type":"tool/code-dispatch","seq":107,"time":1785730479421,"data":{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}} {"type":"tool/result","seq":108,"time":1785730479423,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Era4M5eh79bvNOIey5q90401"},"content":[{"type":"tool-result","toolCallId":"call_00_Era4M5eh79bvNOIey5q90401","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false}],"role":"user","id":"028e19dd-dcfc-4a67-a6e4-c9fa19716ea3"}},"sourceEventSeqs":[105],"surfaceOp":"append"} {"type":"step/end","seq":109,"time":1785730479423,"data":{"turn":1,"step":1}} {"type":"step/start","seq":110,"time":1785730479431,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 24e1525c79..a05477498a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -18,10 +18,10 @@ {"type":"assistant/chunk","seq":187,"time":1785730477079,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":188,"time":1785730477079,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice: `echo CODE_ONE` and `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this."},{"type":"tool-call","id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"59e638d7-2aa2-48a2-ae0e-5833b1152ce6"},"usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187],"surfaceOp":"append"} {"type":"tool/call","seq":189,"time":1785730477080,"data":{"turn":1,"step":1,"callId":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}} -{"type":"tool/code-dispatch-start","seq":190,"time":1785730477131,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}} -{"type":"tool/code-dispatch","seq":191,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}} -{"type":"tool/code-dispatch-start","seq":192,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}} -{"type":"tool/code-dispatch","seq":193,"time":1785730477148,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}} +{"type":"tool/code-dispatch-start","seq":190,"time":1785730477131,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}} +{"type":"tool/code-dispatch","seq":191,"time":1785730477144,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}} +{"type":"tool/code-dispatch-start","seq":192,"time":1785730477144,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}} +{"type":"tool/code-dispatch","seq":193,"time":1785730477148,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}} {"type":"tool/result","seq":194,"time":1785730477150,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_UiQPVqoELyzBZCY5pm1z7875"},"content":[{"type":"tool-result","toolCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","content":[{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}],"isError":false}],"role":"user","id":"e40c6472-d68e-4be1-963f-edb0edc80d82"}},"sourceEventSeqs":[189],"surfaceOp":"append"} {"type":"step/end","seq":195,"time":1785730477150,"data":{"turn":1,"step":1}} {"type":"step/start","seq":196,"time":1785730477158,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 368a2262c8..a4d4289725 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -16,8 +16,8 @@ {"type":"assistant/chunk","seq":14,"time":1785014475639,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785733131056,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9402d85-58bd-4881-b890-0b186f661671"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785733131056,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}} -{"type":"tool/code-dispatch-start","seq":17,"time":1785733131109,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} -{"type":"tool/code-dispatch","seq":18,"time":1785733131110,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} +{"type":"tool/code-dispatch-start","seq":17,"time":1785733131109,"data":{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} +{"type":"tool/code-dispatch","seq":18,"time":1785733131110,"data":{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} {"type":"tool/result","seq":19,"time":1785733131112,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"bde1c12e-44d1-44f7-ba7e-868349ed2b05"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","seq":20,"time":1785733131112,"data":{"turn":1,"step":1}} {"type":"agent/inbox/spliced","seq":21,"time":1785733131112,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"29b0eb87-92d5-4915-ba64-7bd8133ed011"}]}} 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 9fcadd71d4..20f05623ad 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 * 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 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 '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 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 }\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 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 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 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":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"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 * 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 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 '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 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 }\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 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":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"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/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl index b619d352e5..2c294b76d4 100644 --- a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -10,12 +10,13 @@ {"type":"request/context","seq":8,"time":1785730441192,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1785498788105,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}} {"type":"assistant/chunk","seq":10,"time":1785730441201,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}} -{"type":"llm/retry","seq":11,"time":1785730441201,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} -{"type":"assistant/chunk","seq":12,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":13,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":16,"time":1785730441209,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":17,"time":1785730441209,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"422eae65-9975-4a95-8cde-1ddfe21fff4e"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1785730441209,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":19,"time":1785730441209,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"llm/retry","seq":11,"time":1785730441201,"data":{"retryId":"dbe1e1b0-a914-48a4-ad9f-407b213a37ae","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} +{"type":"llm/retry-started","seq":12,"time":1786238385130,"data":{"retryId":"dbe1e1b0-a914-48a4-ad9f-407b213a37ae","turn":1,"step":1,"retry":1}} +{"type":"assistant/chunk","seq":13,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} +{"type":"assistant/chunk","seq":16,"time":1785730441209,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":17,"time":1786238385135,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":18,"time":1786238385135,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"422eae65-9975-4a95-8cde-1ddfe21fff4e"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786238385135,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":20,"time":1786238385135,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 6769414d10..646110b6d9 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -24,8 +24,8 @@ {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} {"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index c342059741..817ee1e1a2 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -23,8 +23,8 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":27,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":29,"time":0,"data":{"turn":1,"step":3}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl index 38056019e2..9e39f76b11 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -1,32 +1,32 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"203f911d-905a-43e4-8e1c-fccfe78d445b"}]}} {"type":"turn/start","seq":1,"time":1786123401614,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1786123401614,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1786123401667,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"203f911d-905a-43e4-8e1c-fccfe78d445b"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1786123401667,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1786123401669,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} {"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} {"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} {"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e54f73a8-572a-40ee-b908-8a8a27b83bf8"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6e7dfb28-f033-4dbf-9fd9-6b2f71da6906"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786123401680,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"b4a6504e-f39d-40b0-b51a-b11fbd60b135"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"4fe4e523-3ac9-421c-8a73-1eb033ac8b09"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1786123401700,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} -{"type":"compact/start","seq":19,"time":1786123401715,"data":{"turn":1}} -{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"6d2afb13-a37b-48d6-9ea5-fc8734127377"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} -{"type":"compact/end","seq":22,"time":1786123401725,"data":{"turn":1}} +{"type":"compact/start","seq":19,"time":1786123401715,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} +{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} +{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"b74220bf-4104-4c30-b967-e4570d5eba5d"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"compact/end","seq":22,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":24,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","seq":25,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","seq":26,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","seq":27,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"346742d0-50e3-4594-b53c-f26c7da82c56"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"103034c2-e156-4808-ba2e-8e17e7107c9e"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1786123401730,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":30,"time":1786123401730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl index 62cc357cc7..c5bca170ba 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl @@ -17,10 +17,10 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"compactionId":"{{sessionId}}","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}}} diff --git a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl index ca523f3665..3c6e45c243 100644 --- a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl @@ -7,13 +7,14 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":9,"time":0,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":9,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry-started","seq":10,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"retry":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":18,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"RETRY_OK","usage":{"inputTokens":4,"outputTokens":2}} diff --git a/packages/bash/bash-env/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts index c93a768f80..eb482fd9d3 100644 --- a/packages/bash/bash-env/tests/bash-env.spec.ts +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -23,6 +23,7 @@ function execution(sessionId?: string): ToolExecution { signal: testToolSignal, token: Symbol('bash-env-test') as ToolExecution['token'], callId: CallId('bash-env-call'), + rootCallId: CallId('bash-env-call'), name: 'bash', arguments: { command: 'true' }, ...(sessionId === undefined diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 24f5668d42..d755f73354 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -96,7 +96,7 @@ function sgr(code: number, body: string): string { } /** - * Terminal output sample for fixture turn 65, authored to carry every feature + * Terminal output sample for fixture turn 66, authored to carry every feature * the terminal card draws that turn 60's two prompt rows cannot reach: * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll @@ -141,7 +141,7 @@ const TERMINAL_EXIT_STATUS: Record, resultText: string, isError = false): void => { push({ type: 'tool/code-dispatch-start', - data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs }, + data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs }, }) push({ type: 'tool/code-dispatch', data: { - parentCallId: callId, subCallId: `${callId}:code:${n}`, name, + rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }], }, }) @@ -476,7 +476,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 71: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 72: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. Two items are // in_progress: this fixture chooses the parallel policy, so both surfaces // must render a parallel plan rather than the first active item alone. @@ -486,7 +486,7 @@ function buildAlphaLog(): SessionEvent[] { { content: '跑后台构建', status: 'in_progress' }, { content: '浏览器验收', status: 'pending' }, ] - // Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover — + // Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover — // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd // whose prompt label is its last segment, and a non-zero exit authored beside // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no @@ -498,45 +498,45 @@ function buildAlphaLog(): SessionEvent[] { // Ordered BEFORE the todo turn deliberately: the standing plan retires at the // next `turn/start`, so a turn appended after it would leave the dock's plan // strip empty and take the todo surfaces' own coverage with it. - toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) - // Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'` + // Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'` // `shape: 'matches'` result view (grouped-by-file matches, truncated with a // larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise // truncated). Both ride the keyed SearchRow registration under their own // names; the render-site fallback row is covered by the model derivation // tests, since every fixture search tool has a keyed row. Ordered before the // todo turn for the same standing-plan reason the bash turn is. - toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) - toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) + toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) + toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) - // Turn 68: the read sample — a WINDOW past an offset so the card draws file + // Turn 69: the read sample — a WINDOW past an offset so the card draws file // line numbers starting above 1 and a "showing N of M" note (the window is // shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path // highlights. Named `read`, so it exercises the keyed ReadRow registration. // The render-site fallback ROW SHAPE (a read call on the generic flattened - // path) is covered by the turn 64 run_code read sub-dispatches, which + // path) is covered by the turn 65 run_code read sub-dispatches, which // session.ts folds with resultView: null; the fallback-row + read-CARD // combination is pinned by the web_fetch case in read-card.spec.tsx, not by // this fixture. The read render intent is result-side only, so its pending // call stays a generic `kind: 'read'` card; presentResult carries the // structured window. - toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) + toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) - // Turns 69-70: the web render intent — a web_search whose result view carries + // Turns 70-71: the web render intent — a web_search whose result view carries // structured sources plus an answer (the citation list, one source lacking a // title so its hostname labels the link, the capped indicator on), and a // web_fetch whose result view carries the fetched URL and its HTTP status. // Both keep a generic pending call view and add the `web` card only at // result time, which is the contract's result-only web shape. Named after // the real tools so they hit the keyed WebRow registration. Ordered BEFORE - // the todo turn for the same reason turn 65 is: the standing plan retires at + // the todo turn for the same reason turn 66 is: the standing plan retires at // the next turn/start, so a turn after it would empty the dock's plan strip. - toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') - toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') + toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') + toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') + toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -578,7 +578,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { case 'read': return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] } case 'edit': - // The multi-hunk sample (turn 67) is keyed on its file_path, so the two + // The multi-hunk sample (turn 64) is keyed on its file_path, so the two // scattered hunks share one path header and the card draws the `⋯` gap. if (str(args.file_path) === 'src/config.ts') { return { diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index e1c07abae3..4aed1d0f36 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/client/runtime/README.md -README.md: bd8528e97b04d5b4b28922266306969e8f19295a -README.zh.md: 9aea486fb17c5a170ee8c1195435d220b495b615 +README.md: b2bb06e50ecd791d74cb609404dd219e5a21913e +README.zh.md: 72cd99ac765875c828cc9163d978e0b8fb7f44e4 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index bd8528e97b..b2bb06e50e 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -34,11 +34,13 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. -## The human transcript +## Conversation assembly -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view with the producer role and name: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the Service Definition's declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). +Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair. -Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. +`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. `ConversationSnapshot.nodes`, `partial`, and `runningCalls` are compatibility slices derived from the same materialized Chat nodes for consumers that have not moved to `ConversationSnapshot.chat`; Session does not run a second business fold. + +The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). ## Request inspection @@ -46,7 +48,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr ## Code Mode child-call tree -Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable. +Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract. ## Session title projection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9aea486fb1..72cd99ac76 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -34,11 +34,13 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。 -## 面向人的 transcript(文本记录) +## Conversation 组装 -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并在与之匹配的 `user/message` 落地时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份包含生产者角色和名称的 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在 Service Definition 的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。 +每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。 -由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 +`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。`ConversationSnapshot.nodes`、`partial` 和 `runningCalls` 是从同一批已物化 Chat 节点派生的兼容切片,供尚未迁移到 `ConversationSnapshot.chat` 的消费者使用;Session 不再运行第二套业务 fold。 + +Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。 ## 请求检查 @@ -46,7 +48,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## Code Mode 子调用树 -每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染。 +每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约。 ## Session 标题投影 diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index b700c4c066..4f1ac11fcc 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -4,12 +4,14 @@ * fiber-scoped loop teardown. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' +import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts' +import { Session } from '../src/client/sessions/session.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, ok } from './fake-api.ts' @@ -112,6 +114,31 @@ describe('runtime client apply', () => { expect(bench.api.callsOf('session.create')).toHaveLength(1) }) + it('wires registry changes into resident Sessions during the runtime apply pass', async () => { + const bench = await mount() + const sessions = bench.ctx.get('sessions') as SessionsService + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r-registry' as never, + payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never, + }) + await flushMicrotasks() + expect(sessions.binding('s-registry' as never)).toBeDefined() + const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') + const definition: ConversationNodeDefinition = { + kind: 'registry-probe', + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + + bench.ctx.conversationEvents.register(definition) + await flushMicrotasks() + + expect(rebuild).toHaveBeenCalledOnce() + rebuild.mockRestore() + }) + it('stops the stream loop when the plugin fiber unloads', async () => { const bench = await mount() const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client')) diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts deleted file mode 100644 index 1f969c5121..0000000000 --- a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Behavioral half of the compaction-checkpoint drift trap. - * - * `TranscriptAdapter` pins its plugin literal to the Service Definition's declaration at - * compile time through a type-only import of `dsh-compact/checkpoint`, so - * renaming the Service Definition's plugin already fails `tsc`. This spec covers the same - * drift from the other side — end to end through the adapter, driving it with a - * checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` value and - * checking the Service Definition's predicate agrees. Both values come from the - * cordis-free checkpoint leaf, so the client test program never loads the host - * package root or its `Context` merges. - */ - -import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts' - -/** A replacement user message stamped with the Service Definition's canonical source. */ -function canonicalCheckpoint(seq: number): SessionEvent { - return { - type: 'user/message', - seq, - time: 1_700_000_000_000 + seq, - surfaceOp: { op: 'replace', start: 0, end: 0 }, - sourceEventSeqs: [0], - data: createUserMessage({ - content: [{ type: 'text', text: 'model only' }], - source: COMPACT_CHECKPOINT_SOURCE, - }), - } as unknown as SessionEvent -} - -describe('compaction checkpoint recognition', () => { - it('recognizes a checkpoint carrying the seam-canonical source', () => { - const adapter = new TranscriptAdapter() - adapter.reset([canonicalCheckpoint(1)]) - expect(adapter.nodes()).toEqual([{ - kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null, - summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, - }]) - }) - - it("agrees with the seam's own predicate on the source it recognizes", () => { - // Both sides answer the same question about the same value: if the Service Definition - // renames its plugin, this equality is what breaks. - const checkpoint = canonicalCheckpoint(1) - expect(checkpoint.type === 'user/message' && isCompactCheckpointSource(checkpoint.data.source)).toBe(true) - expect(COMPACT_CHECKPOINT_SOURCE).toEqual({ kind: 'plugin', plugin: 'compact' }) - }) -}) diff --git a/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.spec.ts new file mode 100644 index 0000000000..6f4d8516dc --- /dev/null +++ b/packages/client/runtime/tests/conversation-assembler.spec.ts @@ -0,0 +1,892 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts' +import type { + ConversationEventInput, ConversationMatch, ConversationNodeContext, + ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, +} from '../src/client/contract/conversation.ts' + +interface ScopeProbeStepData { + readonly value: number +} + +interface ScopeProbeTurnData { + readonly valueSeenFromStep: number +} + +declare module '../src/client/contract/conversation.ts' { + interface ConversationStepDataMap { + 'scope-probe': ScopeProbeStepData + } + + interface ConversationTurnDataMap { + 'scope-probe': ScopeProbeTurnData + } +} + +interface TestSnapshot { + readonly order: readonly string[] + readonly nodes: ReadonlyMap +} + +class TestEventDefinitions { + constructor( + readonly definitions: readonly ConversationNodeDefinition[], + readonly fallback?: ConversationNodeDefinition, + ) {} + + entries(): readonly ConversationNodeDefinition[] { + return this.definitions + } + + fallbackEntry(): ConversationNodeDefinition | undefined { + return this.fallback + } +} + +class TestViewDefinitions { + constructor(readonly definitions: readonly ConversationViewDefinition[]) {} + + entries(): readonly ConversationViewDefinition[] { + return this.definitions + } +} + +function testView( + apply = vi.fn(), +): ConversationViewDefinition { + return { + target: 'chat', + create: () => { + let current: TestSnapshot = { order: [], nodes: new Map() } + return { + empty: current, + replace: ({ nodes }) => { + current = { order: nodes.map(node => node.key), nodes: new Map(nodes.map(node => [node.key, node])) } + return current + }, + apply: ({ upserts }) => { + apply(upserts) + const nodes = new Map(current.nodes) + const order = [...current.order] + for (const node of upserts) { + if (!nodes.has(node.key)) order.push(node.key) + nodes.set(node.key, node) + } + current = { order, nodes } + return current + }, + } + }, + } +} + +function at(seq: number, type: string, data: unknown): SessionEvent { + return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent +} + +function input(event: SessionEvent): ConversationEventInput { + return { event, view: undefined } +} + +function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined { + return assembler.snapshot('chat') as TestSnapshot | undefined +} + +function node(context: Parameters[0], data: unknown): ConversationViewNode { + return { + key: context.key, + kind: context.kind, + id: context.id, + target: 'chat', + data, + } +} + +describe('ConversationNodeAssembler', () => { + it('appends through an exact business-id Context without replaying unrelated Contexts', () => { + const starts = vi.fn(( + _context: ConversationNodeContext<{ callSeq: number; results: number }>, + match: ConversationMatch, + ) => ({ callSeq: match.event.seq, results: 0 })) + const updates = vi.fn((context: { state: { callSeq: number; results: number } }) => ({ + ...context.state, + results: context.state.results + 1, + })) + const definition: ConversationNodeDefinition<{ callSeq: number; results: number }> = { + kind: 'tool', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' } + return null + }, + start: starts, + update: updates, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })), + input(at(2, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })), + ], false) + assembler.flush() + starts.mockClear() + + assembler.append(input(at(3, 'tool/result', { + turn: 1, + step: 1, + message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, + }))) + assembler.flush() + + expect(starts).not.toHaveBeenCalled() + expect(updates).toHaveBeenCalledOnce() + const snapshot = chatSnapshot(assembler) + expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([ + { callSeq: 1, results: 1 }, + { callSeq: 2, results: 0 }, + ]) + }) + + it('keeps one Match collection while a long Context appends without replay', () => { + const starts = vi.fn(() => 0) + const updates = vi.fn((context: ConversationNodeContext & { readonly state: number }) => ( + context.state + 1 + )) + const matchCollections = new Set() + const definition: ConversationNodeDefinition = { + kind: 'append-linear', + match: (event) => { + const type: string = event.type + if (type === 'linear/start') return { id: 'one', role: 'start' } + if (type === 'linear/update') return { id: 'one', role: 'update' } + return null + }, + start: (context) => { + matchCollections.add(context.matches) + return starts() + }, + update: (context) => { + matchCollections.add(context.matches) + return updates(context) + }, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'linear/start', {}))], false) + starts.mockClear() + + for (let seq = 2; seq <= 1_001; seq++) { + assembler.append(input(at(seq, 'linear/update', {}))) + } + assembler.flush() + + expect(starts).not.toHaveBeenCalled() + expect(updates).toHaveBeenCalledTimes(1_000) + expect(matchCollections.size).toBe(1) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000) + }) + + it('merges an older page and replays its affected Context once', () => { + const starts = vi.fn(() => 0) + const updates = vi.fn((context: ConversationNodeContext & { readonly state: number }) => ( + context.state + 1 + )) + const definition: ConversationNodeDefinition = { + kind: 'prepend-linear', + match: (event) => { + const type: string = event.type + if (type === 'linear/start') return { id: 'one', role: 'start' } + if (type === 'linear/update') return { id: 'one', role: 'update' } + return null + }, + start: starts, + update: updates, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + const current = Array.from({ length: 100 }, (_, index) => ( + input(at(index + 102, 'linear/update', {})) + )) + assembler.replaceWindow(current, true) + assembler.flush() + expect(starts).not.toHaveBeenCalled() + expect(updates).not.toHaveBeenCalled() + + const older = [ + input(at(1, 'linear/start', {})), + ...Array.from({ length: 100 }, (_, index) => ( + input(at(index + 2, 'linear/update', {})) + )), + ] + assembler.prepend(older, false) + assembler.flush() + + expect(starts).toHaveBeenCalledOnce() + expect(updates).toHaveBeenCalledTimes(200) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200) + }) + + it('collects an update before its start and replays it once prepend supplies the start', () => { + const updates = vi.fn((context: { state: { settled: boolean } }) => ({ ...context.state, settled: true })) + const definition: ConversationNodeDefinition<{ settled: boolean }> = { + kind: 'tool', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' } + return null + }, + start: () => ({ settled: false }), + update: updates, + buildViewNode: context => node(context, context.state ?? { pendingStart: true }), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(10, 'tool/result', { + turn: 1, + step: 1, + message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, + }))], true) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ pendingStart: true }) + + assembler.prepend([input(at(5, 'tool/call', { + turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}', + }))], false) + assembler.flush() + + expect(updates).toHaveBeenCalledOnce() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ settled: true }) + }) + + it('rejects a Definition whose declared start follows an update in log order', () => { + const definition: ConversationNodeDefinition = { + kind: 'invalid-lifecycle', + match: event => event.type === 'turn/end' + ? { id: 'one', role: 'start' } + : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + + expect(() => assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } })), + ], false)).toThrow('received an update before its start Match') + }) + + it('replays a window-gap reader when prepend supplies a nearer predecessor', () => { + const source: ConversationNodeDefinition = { + kind: 'source', + match: event => event.type === 'user/message' + ? { id: String(event.data.id), role: 'start' } + : null, + start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0), + update: context => context.state, + buildViewNode: () => null, + } + const consumerStart = vi.fn(( + _context: Parameters['start']>[0], + _match: Parameters['start']>[1], + reader: Parameters['start']>[2], + ) => reader.previous('source')?.state ?? -1) + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: consumerStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(10, 'assistant/message', { + turn: 2, step: 1, message: { role: 'assistant', content: [] }, + }))], true) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + + assembler.prepend([input(at(5, 'user/message', { + id: 'm1', value: 7, content: [], source: { kind: 'user' }, + }))], false) + assembler.flush() + + expect(consumerStart).toHaveBeenCalledTimes(2) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7) + }) + + it('keeps the predecessor index ordered across prepend and append', () => { + const source: ConversationNodeDefinition = { + kind: 'source', + match: event => event.type === 'user/message' + ? { id: String(event.data.id), role: 'start' } + : null, + start: (_context, match) => match.event.seq, + update: context => context.state, + buildViewNode: () => null, + } + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: (_context, _match, reader) => reader.previous('source')?.state ?? -1, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(40, 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })), + input(at(50, 'assistant/message', { + turn: 1, step: 1, message: { role: 'assistant', content: [] }, + })), + ], true) + assembler.flush() + + assembler.prepend([ + input(at(10, 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })), + input(at(30, 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })), + ], false) + assembler.flush() + assembler.append(input(at(60, 'user/message', { + id: 'm60', content: [], source: { kind: 'user' }, + }))) + assembler.append(input(at(70, 'assistant/message', { + turn: 2, step: 1, message: { role: 'assistant', content: [] }, + }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + .toEqual([40, 60]) + }) + + it('replays a window-gap reader when an empty prepend closes the unknown prefix', () => { + const consumerStart = vi.fn(( + _context: Parameters['start']>[0], + _match: Parameters['start']>[1], + reader: Parameters['start']>[2], + ) => reader.previous('source')?.state ?? -1) + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: consumerStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(10, 'assistant/message', { + turn: 2, step: 1, message: { role: 'assistant', content: [] }, + }))], true) + assembler.flush() + + expect(assembler.prepend([], false)).toBe('immediate') + assembler.flush() + + expect(consumerStart).toHaveBeenCalledTimes(2) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + }) + + it('replays direct dependents when an append revises their predecessor Context', () => { + const source: ConversationNodeDefinition = { + kind: 'source', + match: (event) => { + if (event.type === 'user/message') return { id: 'one', role: 'start' } + if ((event.type as string) === 'source/update') return { id: 'one', role: 'update' } + return null + }, + start: () => 1, + update: (_context, match) => (match.event.data as unknown as { value: number }).value, + buildViewNode: () => null, + } + const consumerStart = vi.fn(( + _context: Parameters['start']>[0], + _match: Parameters['start']>[1], + reader: Parameters['start']>[2], + ) => reader.previous('source')?.state ?? -1) + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: 'one', role: 'start' } + : null, + start: consumerStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })), + input(at(2, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })), + ], false) + assembler.flush() + + expect(assembler.append(input(at(3, 'source/update', { value: 2 })))).toBe('immediate') + assembler.flush() + + expect(consumerStart).toHaveBeenCalledTimes(2) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2) + }) + + it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => { + const apply = vi.fn() + const starts = vi.fn(( + _context: Parameters['start']>[0], + match: Parameters['start']>[1], + ) => match.location.kind === 'step' ? match.location.step.status : 'missing') + const definition: ConversationNodeDefinition = { + kind: 'step', + match: event => event.type === 'step/start' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: starts, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open') + + assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 }))) + assembler.flush() + + expect(starts).toHaveBeenCalledTimes(2) + expect(apply).toHaveBeenCalledOnce() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed') + }) + + it('lets one Context publish Step and Turn data in phase order', () => { + interface State { + readonly turn: number + readonly step: number + readonly value: number + } + + const definition: ConversationNodeDefinition = { + kind: 'scope-probe', + match: (event) => { + if (event.type === 'step/start') { + return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + } + if ((event.type as string) === 'scope-probe/update') { + return { id: '1:1', role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'step/start') throw new Error('scope probe requires step/start') + return { turn: match.event.data.turn, step: match.event.data.step, value: 1 } + }, + update: (_context, match) => ({ + turn: 1, + step: 1, + value: (match.event.data as unknown as { value: number }).value, + }), + buildLocationData: (context, scope) => { + const state = context.state + if (state === undefined) return null + if (scope === 'step') { + return { + kind: 'step', + turn: state.turn, + step: state.step, + key: 'scope-probe', + value: { value: state.value }, + } + } + const location = context.start?.location + const stepValue = location?.kind === 'step' + ? location.step.data.get('scope-probe')?.value + : undefined + return { + kind: 'turn', + turn: state.turn, + key: 'scope-probe', + value: { valueSeenFromStep: stepValue ?? -1 }, + } + }, + buildViewNode: (context) => { + const location = context.start?.location + if (location?.kind !== 'step') return null + return node(context, { + step: location.step.data.get('scope-probe')?.value, + turn: location.turn.data.get('scope-probe')?.valueSeenFromStep, + }) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ step: 1, turn: 1 }) + + assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ step: 2, turn: 2 }) + }) + + it('updates existing turn Locations when their Step membership changes', () => { + const apply = vi.fn() + const definition: ConversationNodeDefinition = { + kind: 'turn-probe', + match: event => event.type === 'turn/start' + ? { id: String(event.data.turn), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: context => node(context, context.start?.location.kind === 'turn' + ? context.start.location.turn.steps.length + : -1), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0) + + assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 }))) + assembler.flush() + + expect(apply).toHaveBeenCalledOnce() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + }) + + it('publishes a changed timeline even when no business Definition claims the boundary', () => { + const apply = vi.fn() + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([], false) + assembler.flush() + + assembler.append(input(at(1, 'turn/start', { turn: 1 }))) + assembler.flush() + + expect(apply).toHaveBeenCalledOnce() + expect(chatSnapshot(assembler)?.order).toEqual([]) + }) + + it('clears the prior Step at a new Turn and honors explicit session ownership', () => { + const definition: ConversationNodeDefinition = { + kind: 'location-probe', + match: (event) => { + if ((event.type as string) === 'command/run') { + return { + id: (event.data as unknown as { commandId: string }).commandId, + role: 'start', + } + } + if ((event.type as string) === 'compact/start') { + return { + id: (event.data as unknown as { compactionId: string }).compactionId, + role: 'start', + } + } + return null + }, + start: () => null, + update: context => context.state, + buildViewNode: (context) => { + const location = context.start?.location + const data = location?.kind === 'step' + ? `step:${location.turn.turn}:${location.step.step}` + : location?.kind === 'turn' ? `turn:${location.turn.turn}` : location?.kind + return node(context, data) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + input(at(3, 'turn/start', { turn: 2 })), + input(at(4, 'command/run', { commandId: 'command', name: 'x' })), + input(at(5, 'compact/start', { compactionId: 'compact', turn: null })), + ], false) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + .toEqual(['turn:2', 'session']) + }) + + it('assigns turn boundaries to the Turn even when a Step remains open', () => { + const definition: ConversationNodeDefinition = { + kind: 'turn-boundary-probe', + match: event => event.type === 'turn/end' + ? { id: String(event.data.turn), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: context => node(context, context.start?.location.kind), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + + assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn') + }) + + it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => { + const definition: ConversationNodeDefinition = { + kind: 'location-probe', + match: event => (event.type as string) === 'tool/code-dispatch-start' + ? { id: String(event.seq), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: (context) => { + const location = context.start?.location + return node(context, location?.kind === 'step' + ? `${location.turn.turn}:${location.step.step}` + : location?.kind) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), + input(at(11, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })), + ], true) + assembler.flush() + + assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + .toEqual(['2:3', '2:3']) + }) + + it('treats loaded end boundaries as closed when their starts precede the window', () => { + const definition: ConversationNodeDefinition = { + kind: 'location-probe', + match: event => event.type === 'tool/call' + ? { id: String(event.data.callId), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: (context) => { + const location = context.start?.location + return node(context, location?.kind === 'step' + ? `${location.turn.status}:${location.step.status}` + : location?.kind) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), + input(at(11, 'step/end', { turn: 2, step: 3 })), + input(at(12, 'turn/end', { turn: 2, reason: { kind: 'completed' } })), + ], true) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toBe('closed:closed') + }) + + it('restarts State creation from undefined when Location changes replay a Context', () => { + const seen = vi.fn((context: Parameters['start']>[0]) => { + expect(context.state).toBeUndefined() + return 1 + }) + const definition: ConversationNodeDefinition = { + kind: 'replay-probe', + match: event => event.type === 'step/start' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: seen, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'step/start', { turn: 1, step: 1 }))], false) + assembler.flush() + + assembler.append(input(at(2, 'step/end', { turn: 1, step: 1 }))) + assembler.flush() + + expect(seen).toHaveBeenCalledTimes(2) + }) + + it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => { + const fallbackStart = vi.fn(() => 'fallback') + const claimed: ConversationNodeDefinition = { + kind: 'claimed', + match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + const fallback: ConversationNodeDefinition = { + kind: 'fallback', + match: event => ({ id: String(event.seq), role: 'start' }), + start: fallbackStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([claimed], fallback), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + + expect(fallbackStart).not.toHaveBeenCalled() + expect(chatSnapshot(assembler)?.order).toEqual([]) + }) + + it('rejects withdrawing a previously materialized Node during an incremental update', () => { + const definition: ConversationNodeDefinition = { + kind: 'toggle', + match: (event) => { + if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' } + if ((event.type as string) === 'toggle/hide') return { id: 'one', role: 'update' } + return null + }, + start: () => true, + update: () => false, + buildViewNode: context => context.state === true ? node(context, true) : null, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + + assembler.append(input(at(2, 'toggle/hide', {}))) + expect(() => assembler.flush()).toThrow(/withdrew materialized target "chat"/) + + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + }) + + it('fails loud when a Definition returns undefined State', () => { + const startUndefined: ConversationNodeDefinition = { + kind: 'undefined-start', + match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, + start: () => undefined, + update: context => context.state, + buildViewNode: () => null, + } + const startAssembler = new ConversationNodeAssembler( + new TestEventDefinitions([startUndefined]), + new TestViewDefinitions([testView()]), + ) + expect(() => startAssembler.replaceWindow([ + input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + ], false)).toThrow(/Definition "undefined-start" returned undefined from start/) + + const updateUndefined: ConversationNodeDefinition = { + kind: 'undefined-update', + match: (event) => { + if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' } + if ((event.type as string) === 'command/done') return { id: 'one', role: 'update' } + return null + }, + start: () => true, + update: () => undefined as never, + buildViewNode: context => node(context, context.state), + } + const updateAssembler = new ConversationNodeAssembler( + new TestEventDefinitions([updateUndefined]), + new TestViewDefinitions([testView()]), + ) + updateAssembler.replaceWindow([ + input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + ], false) + expect(() => updateAssembler.append( + input(at(2, 'command/done', { commandId: 'one', kind: 'success' })), + )).toThrow(/Definition "undefined-update" returned undefined from update/) + }) + + it('rejects a duplicate start before mutating the existing Context', () => { + const definition: ConversationNodeDefinition = { + kind: 'single-start', + match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, + start: (_context, match) => match.event.seq, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + ], false) + assembler.flush() + + expect(() => assembler.append( + input(at(2, 'command/run', { commandId: 'two', name: 'x' })), + )).toThrow(/received more than one start Match/) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + }) +}) diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts new file mode 100644 index 0000000000..19cbdf17f9 --- /dev/null +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -0,0 +1,126 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' +import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' +import type { + ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, +} from '../src/client/contract/conversation.ts' +import { Session } from '../src/client/sessions/session.ts' +import { SessionsService } from '../src/client/sessions/service.ts' +import { FakeApiClient, ok } from './fake-api.ts' + +function eventDefinition(kind: string): ConversationNodeDefinition { + return { + kind, + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } +} + +function viewDefinition(target: string): ConversationViewDefinition { + return { + target, + create: () => ({ + empty: null, + replace: () => null, + apply: () => null, + }), + } +} + +async function bootRegistries(): Promise<{ + ctx: Context + events: ConversationEventRegistry + views: ConversationViewRegistry +}> { + const ctx = new Context() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() + const events = ctx.get('conversationEvents') as ConversationEventRegistry + const views = ctx.get('conversationViews') as ConversationViewRegistry + return { ctx, events, views } +} + +describe('Conversation registries', () => { + it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => { + const { events } = await bootRegistries() + const definition = eventDefinition('message') + const dispose = events.register(definition) + + expect(events.entries()).toEqual([definition]) + expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.entries()).toEqual([]) + }) + + it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => { + const { events } = await bootRegistries() + const fallback = eventDefinition('unknown') + const dispose = events.registerFallback(fallback) + + expect(events.fallbackEntry()).toBe(fallback) + expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.fallbackEntry()).toBeUndefined() + }) + + it('rejects duplicate view targets and disposes a view registration once', async () => { + const { views } = await bootRegistries() + const definition = viewDefinition('chat') + const dispose = views.register(definition) + + expect(views.entries()).toEqual([definition]) + expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/) + + dispose() + dispose() + expect(views.entries()).toEqual([]) + }) + + it('removes Event, fallback, and view contributions with their caller fiber', async () => { + const { ctx, events, views } = await bootRegistries() + const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => { + featureCtx.conversationEvents.register(eventDefinition('message')) + featureCtx.conversationEvents.registerFallback(eventDefinition('unknown')) + featureCtx.conversationViews.register(viewDefinition('chat')) + }) + await feature.await() + + expect(events.entries()).toHaveLength(1) + expect(events.fallbackEntry()).toBeDefined() + expect(views.entries()).toHaveLength(1) + + await feature.dispose() + expect(events.entries()).toEqual([]) + expect(events.fallbackEntry()).toBeUndefined() + expect(views.entries()).toEqual([]) + }) + + it('coalesces registry changes into one rebuild of every resident Session', async () => { + const { ctx, events, views } = await bootRegistries() + const api = new FakeApiClient() + const sessionId = 'resident' as SessionId + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId, updatedAt: 1, running: false, blank: true }], + }) as never) + const sessions = new SessionsService(ctx, api) + await sessions.refresh() + await Promise.resolve() + sessions.scope(sessionId) + const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') + + events.register(eventDefinition('message')) + views.register(viewDefinition('chat')) + await Promise.resolve() + + expect(rebuild).toHaveBeenCalledOnce() + rebuild.mockRestore() + }) +}) diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index de72d5b351..299e16785d 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -54,12 +54,12 @@ export const ev = { codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent => at(seq, { type: 'tool/code-dispatch-start', - data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args }, + data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args }, }), codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent => at(seq, { type: 'tool/code-dispatch', - data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) }, + data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) }, }), stepEnd: (seq: number, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/end', data: { turn, step } }), diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 55e0929c30..1b6b721d25 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -158,7 +158,6 @@ describe('queue snapshot intake', () => { type: 'session/event', sessionId: SID, event: durable, }) expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1) session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([ { id: 's-later', body: '', placement: 'steering', message }, diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 834f37d434..def97002f0 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -8,15 +8,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' +import type { + ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, + ConversationEventInput, ConversationNode, ConversationNodeDefinition, + ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot, + ConversationViewDefinition, +} from '../src/client/index.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { entries, ev, plainTurn } from './event-script.ts' -const at = (seq: number, e: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent - const SID = 'fk-s1' as SessionId const PARENT = 'fk-parent' as SessionId @@ -24,8 +26,142 @@ afterEach(() => { vi.unstubAllGlobals() }) +const EMPTY: readonly never[] = [] + +interface TestEventState extends ConversationEventInput {} + +class TestNodeStore implements ChatNodeStore { + private readonly nodes = new Map() + private cache: readonly ChatConversationViewNode[] = EMPTY + + get(key: string): ChatConversationViewNode | undefined { + return this.nodes.get(key) + } + + values(): readonly ChatConversationViewNode[] { + return this.cache + } + + replace(nodes: readonly ChatConversationViewNode[]): void { + this.nodes.clear() + for (const node of nodes) this.nodes.set(node.key, node) + this.cache = [...this.nodes.values()] + } + + upsert(nodes: readonly ChatConversationViewNode[]): void { + if (nodes.length === 0) return + for (const node of nodes) this.nodes.set(node.key, node) + this.cache = [...this.nodes.values()] + } +} + +const TEST_LOCATIONS: ChatLocationNodeIndex = { + getTurn: () => EMPTY, + getStep: () => EMPTY, +} + +function testLegacy( + nodes: readonly ChatConversationViewNode[], + timeline: ConversationTimelineSnapshot, +): ChatSnapshot['legacy'] { + const legacyNodes = nodes.flatMap((node): ConversationNode[] => { + const event = (node.data as TestEventState).event + if (event.type === 'user/message') return [{ kind: 'user', seq: event.seq } as ConversationNode] + if (event.type === 'assistant/message') return [{ kind: 'assistant', seq: event.seq } as ConversationNode] + return [] + }) + const turnTimings = new Map() + const turnEnds = new Map() + for (const turn of timeline.turns.values()) { + if (turn.start !== undefined) { + turnTimings.set(turn.turn, turn.end === undefined + ? { startTime: turn.start.time } + : { startTime: turn.start.time, endTime: turn.end.time }) + } + if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq) + } + return { nodes: legacyNodes, turnTimings, turnEnds, partial: null, runningCalls: EMPTY } +} + +function testViewDefinition(): ConversationViewDefinition { + return { + target: 'chat', + create: () => { + const store = new TestNodeStore() + let current: ChatSnapshot = { + order: EMPTY, + nodes: store, + locations: TEST_LOCATIONS, + timeline: { turnOrder: EMPTY, turns: new Map() }, + legacy: testLegacy(EMPTY, { turnOrder: EMPTY, turns: new Map() }), + } + const build = (timeline: ConversationTimelineSnapshot): ChatSnapshot => { + const nodes = [...store.values()].sort((left, right) => left.anchorSeq - right.anchorSeq) + current = { + order: nodes.map(node => node.key), + nodes: store, + locations: TEST_LOCATIONS, + timeline, + legacy: testLegacy(nodes, timeline), + } + return current + } + return { + empty: current, + replace: ({ nodes, timeline }) => { + store.replace(nodes) + return build(timeline) + }, + apply: ({ upserts, timeline }) => { + store.upsert(upserts) + return build(timeline) + }, + } + }, + } +} + +const TEST_EVENT_DEFINITION: ConversationNodeDefinition = { + kind: 'runtime-test-event', + match: event => ({ id: String(event.seq), role: 'start' }), + start: (_context, match) => ({ event: match.event, view: match.view }), + update: context => context.state, + publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate', + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined || context.start === undefined) return null + return { + key: context.key, + kind: 'runtime-test-event', + id: context.id, + target: 'chat', + anchorSeq: context.start.event.seq, + location: context.start.location, + visibility: 'visible', + data: context.state, + } + }, +} + +const TEST_CONVERSATION: ConversationRuntime = { + events: { + entries: () => [TEST_EVENT_DEFINITION], + fallbackEntry: () => undefined, + } as unknown as ConversationRuntime['events'], + views: { + entries: () => [testViewDefinition()], + } as unknown as ConversationRuntime['views'], +} + function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, api) } + return { api, session: new Session(SID, api, { conversation: TEST_CONVERSATION }) } +} + +function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] { + return snapshot.chat.order.map(key => snapshot.chat.nodes.get(key)?.data as TestEventState) +} + +function chatSeqs(snapshot: ConversationSnapshot): number[] { + return chatEvents(snapshot).map(item => item.event.seq) } function histResponse(events: SessionEvent[], hasMore = false) { @@ -34,6 +170,14 @@ function histResponse(events: SessionEvent[], hasMore = false) { } describe('open', () => { + it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { + const { session } = makeSession() + expect(session.getSnapshot()).toMatchObject({ blank: true, composerPhase: 'blank' }) + + session.handleRunning(true) + expect(session.getSnapshot()).toMatchObject({ blank: false, composerPhase: 'active' }) + }) + it('installs the tail page: cold → loading → open with window and nodes in place', async () => { const { api, session } = makeSession() const page = plainTurn(10, 3, '问', '答') @@ -115,74 +259,28 @@ describe('live event path', () => { expect(session.getSnapshot().nodes).toEqual(before.nodes) }) - it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => { - // Live path: run mints an executing node, done settles it in the flow. - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.commandRun(6, 'cmd-live', 'plan')) - let command = session.getSnapshot().nodes.at(-1) - expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null }) - feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode')) - command = session.getSnapshot().nodes.at(-1) - expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } }) - - // Replay path (refresh): the same pair inside the history window folds identically. - const replayed = await opened([ - ...plainTurn(0, 0, 'a', 'b'), - ev.commandRun(6, 'cmd-live', 'plan'), - ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'), - ]) - expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' }, - }) - }) - - it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => { - // A fresh session whose only window content is a command pair (plus the - // knob events a /permission switch appends — not surface-eligible, so - // they never become nodes) stays phase 'blank': selecting a preset from - // the hero must not enter the conversation view. + it('keeps the authoritative host blank bit across unrelated log events', async () => { const { session } = await opened([]) + session.handleBlank(true) expect(session.getSnapshot().composerPhase).toBe('blank') const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')) const snapshot = session.getSnapshot() - expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' }) + expect(chatSeqs(snapshot)).toEqual([0, 1]) expect(snapshot.composerPhase).toBe('blank') }) - it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.user(7, '流式问')) - feed(ev.chunkStart(8, 1)) - feed(ev.chunkText(9, 1, '半截')) - let snapshot = session.getSnapshot() - expect(snapshot.partial).toMatchObject({ turn: 1, blocks: [{ kind: 'text', text: '半截' }] }) - feed(ev.chunkText(10, 1, '回复')) - expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '半截回复' }]) - feed(ev.assistant(11, 1, '半截回复')) - feed(ev.turnEnd(12, 1)) - snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - const last = snapshot.nodes.at(-1) - expect(last).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '半截回复' }] }) - expect((last as { interrupted?: true }).interrupted).toBeUndefined() - }) - - it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => { + it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => { const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.push(callback) return frames.length }) const { session } = await opened() - const published: Array = [] + const published: number[][] = [] session.subscribe(() => { - const block = session.getSnapshot().partial?.blocks[0] - published.push(block?.kind === 'text' ? block.text : null) + published.push(chatSeqs(session.getSnapshot())) }) const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) @@ -195,356 +293,46 @@ describe('live event path', () => { expect(frames).toHaveLength(1) frames.shift()!(0) - expect(published).toEqual(['累计']) + expect(published).toEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8]]) feed(ev.chunkText(9, 1, '完成')) feed(ev.assistant(10, 1, '累计完成')) await Promise.resolve() - expect(published).toEqual(['累计', null]) + expect(published).toEqual([ + [0, 1, 2, 3, 4, 5, 6, 7, 8], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ]) frames.shift()!(0) - expect(published).toEqual(['累计', null]) + expect(published).toHaveLength(2) }) - it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - const retryTurn = [ - ev.turnStart(6, 1), - ev.user(7, '请重试'), - ev.stepStart(8, 1), - ev.chunkStart(9, 1), - ev.chunkText(10, 1, '不完整回复'), - ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'), - ev.chunkStart(12, 1), - ev.assistant(13, 1, '完整回复'), - ev.stepEnd(14, 1), - ev.turnEnd(15, 1), - ] - for (const event of retryTurn.slice(0, 6)) feed(event) - - let snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - expect(snapshot.nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - turn: 1, - step: 0, - provider: 'fake', - mode: 'normal', - policyKey: 'fake-normal', - retry: 1, - maxRetries: 2, - delayMs: 450, - failure: { code: 'TRANSPORT', message: '连接被重置' }, - }) - expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复') - - for (const event of retryTurn.slice(6)) feed(event) - snapshot = session.getSnapshot() - expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant']) - expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false) - expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' }) - expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) - const retryStart = retryTurn.find(event => event.type === 'turn/start') - if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start') - const retryEnd = retryTurn.find(event => - event.type === 'turn/end' && event.data.turn === retryStart.data.turn) - if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn') - expect(snapshot.turnTimings.get(retryStart.data.turn)).toEqual({ - startTime: retryStart.time, - endTime: retryEnd.time, - }) - - const replay = makeSession() - replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn]) - await replay.session.open() - expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes) - expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings) - expect(replay.session.getSnapshot().partial).toBeNull() - }) - - it('projects unretried terminal failures at turn/end and reproduces them from history', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) + it('publishes a timeline-only boundary even when no Definition claims the event', async () => { + const api = new FakeApiClient() + api.onHistory = () => histResponse([]) + const conversation: ConversationRuntime = { + events: { + entries: () => [], + fallbackEntry: () => undefined, + } as unknown as ConversationRuntime['events'], + views: { + entries: () => [testViewDefinition()], + } as unknown as ConversationRuntime['views'], } - const failedTurns = [ - ev.turnStart(6, 1), - ev.user(7, '鉴权失败'), - ev.stepStart(8, 1), - at(9, { - type: 'turn/end', - data: { turn: 1, reason: { kind: 'error', error: { - code: 'AUTH', - message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', - }, - }, - }, - }), - ev.turnStart(10, 2), - ev.user(11, '内部失败'), - ev.stepStart(12, 2, 1), - at(13, { - type: 'turn/end', - data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } }, - }), - ] - for (const event of failedTurns) feed(event) + const session = new Session(SID, api, { conversation }) + await session.open() + const snapshots: ConversationSnapshot[] = [] + session.subscribe(() => { snapshots.push(session.getSnapshot()) }) - const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error') - expect(errors).toMatchObject([ - { seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' }, - // Every failed turn carries a structured failure; unstructured errors - // flatten to the UNKNOWN code. - { seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' }, - ]) - - const replay = makeSession() - replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns]) - await replay.session.open() - expect(replay.session.getSnapshot().nodes).toEqual(session.getSnapshot().nodes) - }) - - it('rejects retry payloads outside the producer contract without retracting the current partial', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.chunkStart(7, 1)) - feed(ev.chunkText(8, 1, '仍在生成')) - const valid = { - turn: 1, step: 0, - provider: 'fake', mode: 'normal', policyKey: 'fake-normal', - retry: 1, maxRetries: 2, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'temporary failure' }, - } - const invalid = [ - { ...valid, turn: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, step: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, provider: '' }, - { ...valid, policyKey: '' }, - { ...valid, retry: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, delayMs: -1 }, - { ...valid, delayMs: Number.POSITIVE_INFINITY }, - { ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 }, - { ...valid, failure: { ...valid.failure, message: '' } }, - { ...valid, failure: { ...valid.failure, code: '' } }, - { ...valid, failure: { ...valid.failure, status: '429' } }, - { ...valid, failure: { ...valid.failure, status: 99 } }, - { ...valid, failure: { ...valid.failure, status: 429.5 } }, - { ...valid, failure: { ...valid.failure, status: 600 } }, - { ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } }, - { ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } }, - { ...valid, failure: { ...valid.failure, requestId: 1 } }, - { ...valid, failure: { ...valid.failure, requestId: '' } }, - ] - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - for (const [index, data] of invalid.entries()) { - feed(at(9 + index, { type: 'llm/retry', data })) - } - expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }]) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([]) - expect(errorSpy).toHaveBeenCalledTimes(invalid.length) - expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9') - } finally { - errorSpy.mockRestore() - } - }) - - it('accepts complete retry payloads at the producer field boundaries', async () => { - const { session } = await opened() - session.handleMuxEnvelope('r' as never, { + session.handleMuxEnvelope('timeline' as never, { type: 'session/event', sessionId: SID, - event: at(6, { - type: 'llm/retry', - data: { - turn: Number.MAX_SAFE_INTEGER, - step: Number.MAX_SAFE_INTEGER, - provider: 'fake', - mode: 'normal', - policyKey: 'fake-normal', - retry: Number.MAX_SAFE_INTEGER, - maxRetries: Number.MAX_SAFE_INTEGER, - delayMs: MAX_TIMER_DELAY_MS, - failure: { - code: 'RATE_LIMIT', - message: 'provider busy', - status: 599, - providerRetryAfterMs: Number.MIN_VALUE, - requestId: 'req-1', - }, - }, - }), + event: ev.turnStart(0, 1), }) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - retry: Number.MAX_SAFE_INTEGER, - delayMs: MAX_TIMER_DELAY_MS, - failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' }, - }) - }) + await Promise.resolve() - it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - feed(at(6, { - type: 'llm/retry', - data: { - turn: 1, step: 0, - provider: 'fake', mode: 'always', policyKey: 'fake-always', - retry: 3, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'retry forever' }, - }, - })) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - mode: 'always', - retry: 3, - }) - - feed(at(7, { - type: 'llm/retry', - data: { - turn: 2, step: 0, - provider: 'fake', mode: 'always', policyKey: 'fake-always', - retry: 4, maxRetries: 4, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'unexpected maximum' }, - }, - })) - feed(at(8, { - type: 'llm/retry', - data: { - turn: 2, step: 0, - provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown', - retry: 4, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'unknown mode' }, - }, - })) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1) - expect(errorSpy).toHaveBeenCalledTimes(2) - } finally { - errorSpy.mockRestore() - } - }) - - it.each(['aborted', 'disposed'] as const)( - 'marks a scheduled retry as cancelled when its failed turn receives the %s cause', - async (reason) => { - const { session } = await opened() - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - feed(ev.turnStart(6, 1)) - feed(ev.retry(7, 1)) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - }) - feed(ev.turnEnd(8, 1, reason)) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'cancelled', - }) - }, - ) - - it('marks a scheduled retry as started when its failed turn ends with an error', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - feed(ev.turnStart(6, 1)) - feed(ev.retry(7, 1)) - feed(at(8, { - type: 'turn/end', - data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } }, - })) - - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'started', - }) - }) - - it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.user(7, '要被打断的')) - feed(ev.chunkStart(8, 1)) - feed(ev.chunkText(9, 1, '说到一半')) - feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives - const snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - expect(snapshot.turnEnds.get(1)).toBe(10) - const frozen = snapshot.nodes.at(-1) - expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] }) - // Ordered inside the flow: after the user message (seq 7), before any later turn. - expect((frozen as { seq: number }).seq).toBeGreaterThan(7) - }) - - it('tracks tool calls in runningCalls and converts orphans to interrupted tool-result cards on turn/end', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'c1', 'echo', '{"a":1}')) - expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'c1', name: 'echo' }]) - feed(ev.toolResult(8, 1, 'c1', 'ECHO')) - expect(session.getSnapshot().runningCalls).toEqual([]) - // Second call never resolves: turn/end freezes it as an error card. - feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}')) - feed(ev.turnEnd(10, 1, 'aborted')) - const snapshot = session.getSnapshot() - expect(snapshot.runningCalls).toEqual([]) - expect(snapshot.nodes.at(-1)).toMatchObject({ - kind: 'tool-result', callId: 'c2', isError: true, error: { code: 'interrupted' }, - }) - }) - - it('keeps compacted history and adds one marker, live and on replay alike', async () => { - // A landed compaction must not erase conversation the reader already saw: - // the shadowed messages stay at their own log positions and the checkpoint - // contributes one marker after them. - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.compactSummary(6, '压缩摘要', 1, 3)) - feed(ev.compactCheckpoint(7, 6, 1, 3)) - const live = session.getSnapshot().nodes - expect(live.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]]) - expect(live.at(-1)).toMatchObject({ kind: 'compaction', summary: '压缩摘要' }) - - const replayed = await opened([ - ...plainTurn(0, 0, 'a', 'b'), - ev.compactSummary(6, '压缩摘要', 1, 3), - ev.compactCheckpoint(7, 6, 1, 3), - ]) - expect(replayed.session.getSnapshot().nodes).toEqual(live) - }) - - it('merges an interrupted frozen node by seq into the log-ordered transcript', async () => { - // The transcript array is seq-monotonic, so the frozen node's fractional - // seq lands it exactly where it happened — including after a compaction - // checkpoint whose own seq is higher than the range it shadowed. - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.compactSummary(6, '压缩摘要', 1, 3)) - feed(ev.compactCheckpoint(7, 6, 1, 3)) - feed(ev.turnStart(8, 1)) - feed(ev.user(9, '压缩后的提问')) - feed(ev.chunkStart(10, 1)) - feed(ev.chunkText(11, 1, '说到一半')) - feed(ev.turnEnd(12, 1, 'aborted')) - expect(session.getSnapshot().nodes.map(n => n.kind)).toEqual([ - 'user', 'assistant', 'compaction', 'user', 'assistant', - ]) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ interrupted: true }) + expect(snapshots).toHaveLength(1) + expect(snapshots[0]?.chat.timeline.turns.get(1)?.status).toBe('open') }) it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { @@ -578,11 +366,7 @@ describe('paging', () => { expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9]) }) - it('renders a page whose checkpoint shadows seqs below the window head, logging nothing', async () => { - // Pagination no longer spends maxMessages quota on replacement copies, so a - // page can carry a compaction checkpoint whose surfaceOp.start lies outside - // the window. The old surface fold rejected that range and degraded with a - // console error; the log-ordered transcript has no range to resolve. + it('installs a page without interpreting business replacement metadata', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse([ ev.compactSummary(80, '窗外范围的摘要', 3, 40), @@ -594,8 +378,7 @@ describe('paging', () => { await session.open() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('open') - expect(snapshot.nodes.map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]]) - expect(snapshot.nodes[0]).toMatchObject({ summary: '窗外范围的摘要' }) + expect(chatSeqs(snapshot)).toEqual([80, 81, 82]) expect(errorSpy).not.toHaveBeenCalled() } finally { errorSpy.mockRestore() @@ -712,6 +495,7 @@ describe('prompt and cancel errors', () => { it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { const { api, session } = makeSession() + session.handleBlank(true) // The blank → engaging edge fires before the RPC settles: the first-send // flow reads the phase on the session area's first frame to keep the // guidance hero from flashing back in. @@ -730,6 +514,7 @@ describe('prompt and cancel errors', () => { it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => { const { api, session } = makeSession() + session.handleBlank(true) api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') expect(result.ok).toBe(false) @@ -969,33 +754,6 @@ describe('remaining branches', () => { } }) - it('freezes only content-bearing partials; a content-free partial is dropped outright', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.chunkStart(7, 1)) // empty text block only, no delta - feed(ev.turnEnd(8, 1, 'aborted')) - const snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([]) - }) - - it('turn/end sweeps only same-turn open calls; other turns keep running', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}')) - feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn - feed(ev.turnEnd(9, 1, 'aborted')) - const snapshot = session.getSnapshot() - expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call']) - expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true }) - }) - it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => { const { api, session } = makeSession() const stale = deferred>>() @@ -1065,28 +823,13 @@ describe('remaining branches', () => { expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) }) - it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => { + it('successful cancel leaves no promptError', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() const result = await session.cancel() expect(result.ok).toBe(true) expect(session.getSnapshot().promptError).toBeNull() - const callsBefore = session.getSnapshot().runningCalls - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(6, 0, 'never-called', 'x') }) - expect(session.getSnapshot().runningCalls).toBe(callsBefore) // callsRev untouched: same reference - }) - - it('freezes a tool-call-only partial (visible through the non-text arm)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } })) - feed(ev.turnEnd(8, 1, 'aborted')) - const frozen = session.getSnapshot().nodes.at(-1) - expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] }) }) it('dispose is a reserved no-op on resident instances', () => { @@ -1094,7 +837,7 @@ describe('remaining branches', () => { expect(() => { session.dispose() }).not.toThrow() }) - it('carries mux-frame views into runningCalls and tool-result nodes, and history-entry views through open', async () => { + it('carries history-entry and mux-frame views into the business-neutral Event input', async () => { const { api, session } = makeSession() const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } } api.onHistory = () => Promise.resolve(ok({ @@ -1107,21 +850,23 @@ describe('remaining branches', () => { modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) await session.open() - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'tool-result', callView: { title: '历史卡' }, resultView: { title: '历史果' }, - }) - // Live path: the frame's view slot reaches runningCalls, then the result node. + expect(chatEvents(session.getSnapshot()).slice(-2).map(item => item.view)).toEqual([ + callView, + { for: 'result', view: { card: 'generic', title: '历史果' } }, + ]) session.handleMuxEnvelope('rv1' as never, { type: 'session/event', sessionId: SID, event: ev.toolCall(8, 2, 'l1', 'write', '{}'), view: { for: 'call', view: { card: 'generic', title: '直播卡' } }, } as never) - expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'l1', callView: { title: '直播卡' } }]) + expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ + for: 'call', view: { card: 'generic', title: '直播卡' }, + }) session.handleMuxEnvelope('rv2' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(9, 2, 'l1', 'ok'), view: { for: 'result', view: { card: 'generic', title: '直播果' } }, } as never) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'tool-result', callView: { title: '直播卡' }, resultView: { title: '直播果' }, + expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ + for: 'result', view: { card: 'generic', title: '直播果' }, }) }) }) @@ -1177,163 +922,27 @@ describe('resync', () => { }) -describe('nested run_code sub-dispatches', () => { - const subCallsOf = (session: Session, callId: string) => { - const snapshot = session.getSnapshot() - const running = snapshot.runningCalls.find(call => call.callId === callId) - if (running !== undefined) return running.subCalls - for (const node of snapshot.nodes) { - if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls - } - return undefined - } - - it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) - feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' })) - feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' })) - const live = subCallsOf(session, 'p1') - expect(live).toHaveLength(2) - // Running shape (no 'kind'): the exact RunningToolCall form native rows use. - expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' }) - expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false) - // Settle out of order (parallel run): #2 first — replaces in place, keeping start order. - feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha')) - const mixed = subCallsOf(session, 'p1') - expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true]) - expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] }) - // The settle carries the paired start's time as callTime (duration source). - feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done')) - const settled = subCallsOf(session, 'p1') - expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true]) - expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 }) - }) - - it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}')) - feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt')) - feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true)) - const subs = subCallsOf(session, 'p1') - expect(subs).toHaveLength(2) - expect(subs?.[0]).toMatchObject({ - kind: 'tool-result', callId: 'p1:code:1', - call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' }, - // The settle event carries no start time: callTime stays null (never a - // fabricated zero-duration). - callTime: null, - isError: false, content: [{ type: 'text', text: 'demo.txt' }], - }) - expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true }) - // No paired start in the window: duration is UNKNOWN (null), never a - // fabricated zero-duration span. - expect(subs?.[0]).toMatchObject({ callTime: null }) - // Sub-dispatches never join the surface flow. - expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false) - }) - - it('rebuilds the same nested tree from a history window (replay parity)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse([ - ...plainTurn(0, 0, '问', '答'), - ev.turnStart(6, 1), - ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'), - ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }), - ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'), - ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'), - ev.toolResult(11, 1, 'p1', '{"done":true}'), - ev.turnEnd(12, 1), - ]) - await session.open() - const subs = subCallsOf(session, 'p1') - expect(subs).toHaveLength(1) - expect(subs?.[0]).toMatchObject({ - callId: 'p1:code:1', - call: { name: 'run_code' }, - subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }], - }) - }) - - it('keeps an unaffected root reference and path-copies it on a new child', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) - feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x')) - const before = session.getSnapshot() - const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')! - feed(ev.chunkStart(9, 1)) - feed(ev.chunkText(10, 1, '流式')) - const after = session.getSnapshot() - const afterRoot = after.runningCalls.find(call => call.callId === 'p1')! - expect(afterRoot).toBe(beforeRoot) - feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y')) - const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')! - expect(changedRoot).not.toBe(afterRoot) - expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0]) - expect(changedRoot.subCalls).toHaveLength(2) - }) - - it('path-copies only the owning branch when a nested child changes', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}')) - feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}')) - feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child')) - feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling')) - feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two')) - const before = session.getSnapshot() - const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')! - const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')! - const beforeChild = beforeFirst.subCalls[0]! - const beforeSibling = beforeFirst.subCalls[1]! - - feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf')) - const after = session.getSnapshot() - const afterFirst = after.runningCalls.find(call => call.callId === 'p1')! - const afterSecond = after.runningCalls.find(call => call.callId === 'p2')! - - expect(afterFirst).not.toBe(beforeFirst) - expect(afterSecond).toBe(beforeSecond) - expect(afterFirst.subCalls[0]).not.toBe(beforeChild) - expect(afterFirst.subCalls[1]).toBe(beforeSibling) - expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([ - { callId: 'p1:code:1:code:1', call: { name: 'read' } }, - ]) - }) -}) - describe('reference stability (the memo contract)', () => { it('keeps unchanged node references across an append and swaps the snapshot object', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) await session.open() const before = session.getSnapshot() + const firstKey = before.chat.order[0]! + const secondKey = before.chat.order[1]! + const first = before.chat.nodes.get(firstKey) + const second = before.chat.nodes.get(secondKey) session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') }) const after = session.getSnapshot() expect(after).not.toBe(before) // top-level swap on change - expect(after.nodes[0]).toBe(before.nodes[0]) // untouched nodes keep identity - expect(after.nodes[1]).toBe(before.nodes[1]) - expect(after.nodes).toHaveLength(3) + expect(after.chat.nodes.get(firstKey)).toBe(first) + expect(after.chat.nodes.get(secondKey)).toBe(second) + expect(after.chat.order).toHaveLength(7) // No change → same snapshot reference. expect(session.getSnapshot()).toBe(after) }) - it('keeps untouched substructure arrays identical across unrelated changes (revision counters)', async () => { + it('keeps unrelated Session arrays and settled Chat Nodes stable across Event updates', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '底', '座')) await session.open() @@ -1343,20 +952,19 @@ describe('reference stability (the memo contract)', () => { feed(ev.toolCall(8, 1, 'c1', 'echo', '{}')) session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) const before = session.getSnapshot() - // A chunk storm touches partial/nodes only: unrelated projections keep identity. + const settledKey = before.chat.order[0]! + const settledNode = before.chat.nodes.get(settledKey) feed(ev.chunkStart(9, 1)) feed(ev.chunkText(10, 1, '与工具无关的流式')) const after = session.getSnapshot() expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) - expect(after.turnTimings).toBe(before.turnTimings) - expect(after.turnEnds).toBe(before.turnEnds) - // And a mutation on the tracked domain swaps that array. + expect(after.chat.nodes.get(settledKey)).toBe(settledNode) feed(ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() - expect(resolved.runningCalls).not.toBe(after.runningCalls) expect(resolved.pending).toBe(after.pending) + expect(resolved.chat.nodes.get(settledKey)).toBe(settledNode) feed(ev.assistant(12, 1, '完成')) expect(session.getSnapshot()).not.toBe(resolved) }) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts deleted file mode 100644 index b161046f84..0000000000 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ /dev/null @@ -1,567 +0,0 @@ -/** - * TranscriptAdapter over the raw append-only window: log-ordered projection of - * append-origin events, one marker per landed compaction, replacement copies - * hidden, command-lifecycle folding, node/array identity, call pairing, and - * host-provided wire views. - */ - -import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' -import { describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts' -import { ev, plainTurn } from './event-script.ts' - -const at = (seq: number, e: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent - -/** A `compact/summary` event (log-only, no surfaceOp). */ -function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent { - return at(seq, { - type: 'compact/summary', - data: { - summary, - shadowedRange: { start: 1, end: 3 }, - shadowedSeqs: [1, 3], - shadowedTokenCount: 100, - provider: 'fake', - model: 'compact-1', - }, - }) -} - -/** The replacement user message a compaction backend lands (the checkpoint). */ -function checkpoint( - seq: number, - summarySeq: number, - { start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: { - start?: number - end?: number - sourceEventSeqs?: number[] - } = {}, -): SessionEvent { - return at(seq, { - type: 'user/message', - surfaceOp: { op: 'replace', start, end }, - sourceEventSeqs, - data: createUserMessage({ - content: [{ type: 'text', text: 'model only' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), - }) -} - -describe('TranscriptAdapter', () => { - it('projects a window starting past seq 0 at its own log positions', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(100, 5, '偏移问', '偏移答')) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]]) - }) - - it('appends incrementally keeping old node references (materialize-once identity)', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - const first = adapter.nodes() - adapter.append(ev.user(6, '追加')) - const second = adapter.nodes() - expect(second).toHaveLength(3) - expect(second[0]).toBe(first[0]) - expect(second[1]).toBe(first[1]) - expect(second).not.toBe(first) // a real change swaps the array - }) - - it('keeps the array reference across a chunk storm and swaps it when a node lands', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - const settled = adapter.nodes() - adapter.append(ev.chunkStart(6, 1)) - expect(adapter.nodes()).toBe(settled) - adapter.append(ev.chunkText(7, 1, '流式')) - expect(adapter.nodes()).toBe(settled) - adapter.append(ev.assistant(8, 1, '流式完成')) - const finalized = adapter.nodes() - expect(finalized).not.toBe(settled) - expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 }) - }) - - it('materializes every append-origin variant with field mapping', () => { - const adapter = new TranscriptAdapter() - const steering = createUserMessage({ - content: [{ type: 'text', text: '插话' }], - source: { kind: 'user' }, - }) - adapter.reset([ - ev.user(0, '用户'), - ev.assistant(1, 0, '助手'), - at(2, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } }), - at(3, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(4, { type: 'user/message', surfaceOp: 'append', data: steering }), - at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' }, - }) }), - ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'), - ev.toolResult(7, 0, 'c1', '结果'), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result']) - expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id }) - expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({ - callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false, - }) - }) - - it('identifies steering on the live append path', () => { - const adapter = new TranscriptAdapter() - const steering = createUserMessage({ - content: [{ type: 'text', text: 'live steer' }], - source: { kind: 'user' }, - }) - adapter.reset([]) - adapter.append(at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } })) - adapter.append(at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } })) - adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering })) - expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }]) - }) - - it('does not mark queued, canceled, or non-user next-step messages as steering', () => { - const adapter = new TranscriptAdapter() - const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) - const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } }) - const context = createUserMessage({ - content: [{ type: 'text', text: 'context' }], - source: { kind: 'plugin', plugin: 'test' }, - }) - adapter.reset([ - at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-turn', start: 0, inserted: [queued], - } }), - at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-turn', start: 0, removedCount: 1, inserted: [], - } }), - at(2, { type: 'user/message', surfaceOp: 'append', data: queued }), - at(3, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [canceled], - } }), - at(4, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled', - } }), - at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }), - at(6, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [context], - } }), - at(7, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(8, { type: 'user/message', surfaceOp: 'append', data: context }), - ]) - expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) - }) - - it('materializes a skill-invocation injection as a named instructions context', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '/hidden-demo check the fixture' }], - source: { kind: 'user' }, - }) }), - at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'body' }], - source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never, - }) }), - ]) - const nodes = adapter.nodes() - // The gesture stays a user bubble; the injected body folds to a context - // row named after the skill, presented as instructions. - expect(nodes.map(node => node.kind)).toEqual(['user', 'context']) - expect(nodes[1]).toMatchObject({ - provenance: { role: 'inject', label: 'hidden-demo' }, - form: 'instructions', - }) - }) - - it('skips events core does not call surface-eligible, marker or not', () => { - // The transcript is the append-origin surface, so log-only events (a chunk, - // a turn boundary, a `compact/*` record) and a future type core - // has not admitted contribute no node. - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.turnStart(0, 1), - at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }), - compactSummary(2), - ev.user(3, '唯一的一条'), - ev.turnEnd(4, 1), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]]) - }) - - describe('compaction markers', () => { - it('keeps the original messages and full tool output, hiding replacement copies', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '原始问题'), - ev.assistant(1, 0, '原始回答'), - ev.toolCall(4, 0, 'c1', 'echo', '{}'), - ev.toolResult(5, 0, 'c1', '完整工具输出'), - // A pruned tool/result copy: rewrites one node for the model, marks nothing. - at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: { - turn: 0, step: 0, - message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }), - } }), - compactSummary(7), - checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }), - // A regenerated assistant/message: also a silent model-only rewrite. - at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: { - turn: 0, step: 0, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: '通用 replacement 副本' }], - source: { kind: 'model', ...{ provider: 'x', model: 'copy' } }, - }), - } }), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([ - ['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8], - ]) - expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] }) - expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' }) - }) - - it('adds one marker per landed compaction, in log order', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, 'a'), - compactSummary(1, [{ type: 'text', text: 'first' }]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ev.user(3, 'b'), - compactSummary(4, [{ type: 'text', text: 'second' }]), - checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }), - ]) - expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([ - { - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first', - summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - { - kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second', - summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - ]) - }) - - it('renders the marker when the shadowed range is outside the window and logs nothing', () => { - // The pagination hole A1 left open: quota is no longer spent on - // replacement copies, so a page can carry a checkpoint whose - // surfaceOp.start lies below the window head. The old surface fold threw - // on the missing range and degraded with a console error; a log-ordered - // projection has no range to resolve. - const adapter = new TranscriptAdapter() - const noise = { error: console.error, warn: console.warn } - const logged: unknown[] = [] - console.error = (...args: unknown[]) => logged.push(args) - console.warn = (...args: unknown[]) => logged.push(args) - try { - adapter.reset([ - compactSummary(80, [{ type: 'text', text: '窗外范围' }]), - checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }), - ev.user(82, '压缩后的新问题'), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]]) - expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' }) - } finally { - console.error = noise.error - console.warn = noise.warn - } - expect(logged).toEqual([]) - }) - - it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => { - // A session-reference card carries the same plugin source shape; only the - // replacement marker makes an event a checkpoint. - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '注入的上下文' }], - source: { kind: 'plugin', plugin: 'compact', form: 'instructions' }, - }) }), - ]) - expect(adapter.nodes()).toMatchObject([{ - kind: 'context', - seq: 0, - provenance: { role: 'inject', label: 'compact' }, - form: 'instructions', - }]) - }) - - it('ignores a foreign plugin s replacement user/message', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '保留'), - at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({ - content: [{ type: 'text', text: '别的插件重写' }], - source: { kind: 'plugin', plugin: 'not-compact' }, - }) }), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]]) - }) - - it.each([ - ['absent summary event', undefined], - ['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])], - ['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])], - ['an empty summary array', compactSummary(1, [])], - ['a non-array summary', compactSummary(1, 'plain string')], - ])('degrades %s to a non-expandable marker', (_label, summary) => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ...(summary === undefined ? [] : [summary]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ]) - expect(adapter.nodes()).toMatchObject([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }, - ]) - }) - - it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => { - // ContentBlock is merge-extensible and the payload type is ContentBlock[], - // so a non-text block must not discard recoverable text beside it. - const adapter = new TranscriptAdapter() - adapter.reset([ - compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ]) - expect(adapter.nodes()).toEqual([ - { - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要', - summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - ]) - }) - - it('leaves the summary null when the checkpoint cites no source events', () => { - const adapter = new TranscriptAdapter() - adapter.reset([at(2, { - type: 'user/message', - surfaceOp: { op: 'replace', start: 0, end: 0 }, - data: createUserMessage({ - content: [{ type: 'text', text: 'x' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), - })]) - expect(adapter.nodes()).toEqual([{ - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null, - summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, - }]) - }) - - it('skips a cited non-summary seq before reaching the summary event', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '被压缩的问题'), - at(1, { type: 'compact/start', data: { turn: 0 } }), - compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]), - checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }), - ]) - expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' }) - }) - - it('resolves the summary once an older page supplies the cited summary event', () => { - const adapter = new TranscriptAdapter() - const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] }) - adapter.reset([landed]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null }) - adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' }) - }) - - it('creates the marker on the live append path', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }])) - adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] })) - const nodes = adapter.nodes() - // The compacted history is still there; the marker is one more row after it. - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]]) - expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' }) - }) - }) - - it('returns call:null for a tool-result whose call fell outside the window', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null }) - }) - - it('materializes a tool-result error field when present', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'tool/result', surfaceOp: 'append', data: { - turn: 0, step: 0, - message: createToolResultMessage({ callId: CallId('c1'), content: [], isError: true }), - error: { name: 'Boom', code: 'boom' }, - } }), - ]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } }) - }) - - it('attaches wire views to the materialized result node', () => { - const adapter = new TranscriptAdapter() - const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } } - const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } } - adapter.reset([ - ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'), - ev.toolResult(1, 1, 'c1', 'listing'), - ], [callView, resultView] as never) - expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ - callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' }, - }) - }) - - it('attaches views on the live append path and defaults to null without views', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument - adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never) - adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result - expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ - callView: { title: '回声' }, resultView: null, - }) - }) - - it('leaves callView null when the paired call fell outside the window (cross-page break)', () => { - const adapter = new TranscriptAdapter() - const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } } - adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' }, - }) - }) - - describe('command lifecycle nodes', () => { - it('folds a run/done pair into one settled node merged into flow order by seq', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '先说话'), - ev.commandRun(1, 'cmd-1', 'plan'), - ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'), - ev.assistant(3, 0, '然后回答'), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]]) - expect(nodes[1]).toMatchObject({ - kind: 'command', commandId: 'cmd-1', name: 'plan', args: '', - outcome: { kind: 'success', text: '已进入 plan mode' }, - }) - }) - - it('renders a run with no done as still executing (outcome null)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null }) - }) - - it('represents command input omitted by the host as null', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')]) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'command', name: 'feedback', args: null, outcome: null, - }) - }) - - it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')]) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null, - outcome: { kind: 'error', text: '失败了' }, - }) - }) - - it('settles a live-appended done in place, keeping the node at the run seq', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'q', 'a')) - adapter.append(ev.commandRun(6, 'cmd-4', 'clear')) - const running = adapter.nodes().find(n => n.kind === 'command') - expect(running).toMatchObject({ outcome: null }) - adapter.append(ev.commandDone(7, 'cmd-4')) - const settled = adapter.nodes().find(n => n.kind === 'command') - expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } }) - // Settlement replaced the node object rather than mutating the published one. - expect(settled).not.toBe(running) - }) - - it('tails command nodes whose seq is past every transcript node', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')]) - expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command']) - }) - - it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '压缩前的问题'), - ev.commandRun(1, 'cmd-compact', 'compact'), - compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]), - checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }), - ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]]) - expect(nodes[1]).toMatchObject({ - name: 'compact', - outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 }, - }) - expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 }) - }) - }) - - describe('assistant timing', () => { - const base = 1_700_000_000_000 - - it('derives step timing across a window rebuild (start + first token + completion)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.turnStart(0, 0), - ev.user(1, '问'), - ev.stepStart(2, 0), - ev.chunkStart(3, 0), - ev.chunkText(4, 0, '答'), - ev.chunkText(5, 0, '案'), - ev.assistant(6, 0, '答案'), - ev.turnEnd(7, 0), - ]) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 }, - }) - }) - - it('derives the same timing on the live append path, first token winning once', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.user(0, '问')]) - adapter.append(ev.stepStart(1, 0)) - adapter.append(ev.chunkText(2, 0, '首')) - adapter.append(ev.chunkText(3, 0, '次')) - adapter.append(ev.assistant(4, 0, '首次')) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 }, - }) - }) - - it('soft-falls to null boundaries when the step opening fell outside the window', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.assistant(100, 0, '被切窗的答案')]) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 }, - }) - }) - }) -}) diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index ea01893346..7f65a0b5a3 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -2,6 +2,7 @@ import type { ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' /** * Fixture overrides for the session behavior face: any subset of the @@ -45,6 +46,7 @@ export interface SessionFixture { export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot { return { sessionId, + chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 3005c061e7..0b8c732683 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -22,7 +22,9 @@ import { act, render, within } from '@testing-library/react' import type { RenderResult } from '@testing-library/react' import type { queries } from '@testing-library/dom' import type { BoundFunctions } from '@testing-library/dom' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationViewRegistry, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' import type { ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike, @@ -142,7 +144,7 @@ export class TestRoot { */ async declare( children: D, - frame: SlotComponent & keyof SlotMap & string, undefined, object>>, + frame: SlotComponent & keyof SlotMap & string, undefined, object>>, ): Promise { await this.stabilize(() => { // Erased hop (same pattern as SlotsService's own implementation arm); @@ -218,6 +220,8 @@ export class SlotTestRuntime { const ctx = new Context() const fiber = ctx.plugin(SlotsService) await fiber.await() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService) } diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 00001275d2..3481ee758e 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -53,7 +53,7 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => { + it('registers the chat view and its keyed business-node seat', async () => { const b = await bench() const entries = b.slots.entries('conversation.view') expect(entries.map(e => e.options.id)).toEqual(['chat']) @@ -62,7 +62,7 @@ describe('apply wiring', () => { expect(entries[0]?.options.order).toBe(0) // Declaring is claiming: the chat entry's registration put the hole on // the ledger with the contract's kind/scope. - expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' }) + expect(b.slots.spec('conversation.chat.node')).toEqual({ kind: 'keyed', scope: 'session' }) await b.runtime.dispose() }) @@ -95,7 +95,7 @@ describe('apply wiring', () => { // file-mutation registrant claims both write and edit for the diff card; the // one search row registers under both grep and glob; the web rows register // one component under both web tool names. - expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0) + expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).not.toContain('tool-call') // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() @@ -108,8 +108,8 @@ describe('apply wiring', () => { // The declared ring collapses with its declaring entry, and the chat // entry's keyed hole (with the sample's registration) collapses with it. expect(b.slots.entries('conversation.view')).toHaveLength(0) - expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0) - expect(b.slots.spec('conversation.chat.tool')).toBeUndefined() + expect(b.slots.entries('conversation.chat.node')).toHaveLength(0) + expect(b.slots.spec('conversation.chat.node')).toBeUndefined() expect(b.slots.entries('details')).toHaveLength(0) expect(b.slots.entries('settings.general.item')).toHaveLength(0) expect(b.runtime.ctx.get('conversation')).toBeUndefined() diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 0362c3ee4b..6037405336 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -10,13 +10,21 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { + ChatConversationViewNode, ConversationNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNodeViewProps } from '../src/client/contract/slots.ts' import { formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, } from '../src/client/chat/message-chrome.ts' -import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx' +import { + CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView, + UserMessageNodeView, +} from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { zh } from '../src/client/locales.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ class ResizeObserverStub { @@ -33,7 +41,44 @@ afterEach(() => { }) // Mirrors the real lookup chain (conversation namespace, then common). -const t: MessageItemProps['t'] = makeTranslate(zh, commonZh) +const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) +const RETRY_ID = 'retry-fixture' as Extract['retryId'] + +interface MessageItemProps { + readonly node: ConversationNode + readonly t: ChatNodeViewProps['t'] +} + +/** Legacy-node fixture adapter for the independently registered renderers. */ +function MessageItem({ node, t: translate }: MessageItemProps) { + const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind + const viewNode: ChatConversationViewNode = { + key: `fixture:${node.kind}:${node.seq}`, + kind, + id: String(node.seq), + target: 'chat', + anchorSeq: node.seq, + location: { kind: 'session' }, + visibility: 'visible', + data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node, + } + const props = { node: viewNode, t: translate } as ChatNodeViewProps + switch (node.kind) { + case 'user': + case 'steering': + return } /> + case 'context': + return } /> + case 'compaction': + return } /> + case 'model-retry': + return } /> + case 'unknown': + return } /> + default: + throw new Error(`unsupported MessageItem fixture kind: ${node.kind}`) + } +} describe('MessageItem arms', () => { it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => { @@ -727,9 +772,9 @@ describe('MessageItem arms', () => { const view = render( { view.rerender( { view.rerender( { view.rerender( { view.rerender( { expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s') }) - it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => { - vi.useFakeTimers() - vi.setSystemTime(10_000) - const node = { - kind: 'model-retry', - seq: 5, - time: 10_000, - retryState: 'scheduled', - turn: 1, - step: 0, - provider: 'mock', - mode: 'normal', - policyKey: 'mock-normal', - retry: 1, - maxRetries: 2, - delayMs: 5_000, - failure: { code: 'TRANSPORT', message: '连接被重置' }, - } as const - const view = render() - expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s') - - act(() => { vi.advanceTimersByTime(4_200) }) - view.rerender() - expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') - }) - }) describe('formatMessageClock', () => { @@ -932,84 +954,13 @@ describe('small branch tails', () => { expect(view.getByText('one-liner')).toBeTruthy() }) - it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => { - const writeText = vi.fn().mockResolvedValue(undefined) - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }) - const now = new Date() - const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() - const onFork = vi.fn() - const settled = render( - , - ) - expect(settled.getByText('14:24')).toBeTruthy() - expect(settled.getByRole('button', { name: '复制' })).toBeTruthy() - expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() - fireEvent.click(settled.getByRole('button', { name: '复制' })) - expect(writeText).toHaveBeenCalledWith('answer body') - fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' })) - expect(onFork).toHaveBeenCalledWith(3) - settled.unmount() - - const thinkOnly = render( - , - ) - expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull() - expect(thinkOnly.queryByText('14:24')).toBeNull() - thinkOnly.unmount() - - const streaming = render( - , - ) - expect(streaming.queryByRole('button', { name: '复制' })).toBeNull() - expect(streaming.queryByText('14:24')).toBeNull() - }) - - it('keeps an unavailable branch focusable and explains why without sending a fork', () => { - const onFork = vi.fn() - render( - , - ) - const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement - expect(branch.disabled).toBe(false) - expect(branch.getAttribute('aria-disabled')).toBe('true') - const reasonId = branch.getAttribute('aria-describedby') - expect(reasonId).not.toBeNull() - expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支') - fireEvent.click(branch) - expect(onFork).not.toHaveBeenCalled() - fireEvent.focus(branch) - expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支') - }) - it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => { // Cache hit is null only when all three prompt buckets are zero (pure // output accounting) — any billed input makes it a real 0%. - const snap = { - nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }], - } + const nodes = [{ + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 }, + }] as const + const snap = { chat: chatSnapshotFixture({ nodes }), nodes } const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( (left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function nodeSource(node: ChatConversationViewNode): unknown { + if (node.kind === 'assistant-step') { + const data = node.data as ReturnType + return data.finalNode ?? data.blocks + } + if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root + if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current + if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq + return node.data +} + +class FixtureNodeStore implements ChatNodeStore { + private byKey = new Map() + private list: readonly ChatConversationViewNode[] = EMPTY + + get(key: string): ChatConversationViewNode | undefined { + return this.byKey.get(key) + } + + values(): readonly ChatConversationViewNode[] { + return this.list + } + + replace(candidates: readonly ChatConversationViewNode[]): void { + const next = new Map() + const list = candidates.map((candidate) => { + const previous = this.byKey.get(candidate.key) + const node = previous !== undefined + && previous.kind === candidate.kind + && previous.anchorSeq === candidate.anchorSeq + && previous.visibility === candidate.visibility + && nodeSource(previous) === nodeSource(candidate) + ? previous + : candidate + next.set(node.key, node) + return node + }) + this.byKey = next + this.list = sameValues(this.list, list) ? this.list : list + } +} + +class FixtureLocationIndex implements ChatLocationNodeIndex { + private turns = new Map() + + getTurn(turn: number): readonly string[] { + return this.turns.get(turn) ?? EMPTY + } + + getStep(): readonly string[] { + return EMPTY + } + + replace(next: ReadonlyMap): void { + const stable = new Map() + for (const [turn, keys] of next) { + const previous = this.turns.get(turn) ?? EMPTY + stable.set(turn, sameValues(previous, keys) ? previous : keys) + } + this.turns = stable + } +} + +class FixtureTurnDataStore implements ConversationLocationDataStore { + private readonly values = new Map() + + get( + key: Key, + ): Readonly | undefined { + return this.values.get(key) as Readonly | undefined + } + + set(key: Key, value: ConversationTurnDataMap[Key]): void { + this.values.set(key, value) + } +} + +function assistantData(node: AssistantMessageNode) { + return { + status: node.interrupted === true ? 'interrupted' as const : 'settled' as const, + turn: node.turn, + step: node.step, + blocks: node.blocks, + time: node.time, + finalNode: node, + } +} + +function settledNode( + node: ConversationNode, + turns: ReadonlyMap, +): ChatConversationViewNode { + const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined + const base = { + key: `fixture:${node.kind}:${node.seq}`, + id: String(node.seq), + target: 'chat' as const, + anchorSeq: node.seq, + location: turn === undefined + ? { kind: 'session' as const } + : { kind: 'turn' as const, turn }, + visibility: 'visible' as const, + } + switch (node.kind) { + case 'assistant': + return { ...base, kind: 'assistant-step', data: assistantData(node) } + case 'tool-result': + return { ...base, key: `fixture:tool:${node.callId}`, kind: 'tool-call', data: { root: node } } + case 'model-retry': + return { ...base, key: 'fixture:model-retry', kind: 'model-retry', data: { attempts: [node], current: node } } + default: + return { ...base, kind: node.kind, data: node } + } +} + +/** Build the canonical Chat fixture corresponding to one legacy test slice. */ +export function chatSnapshotFixture(input: { + readonly nodes?: readonly ConversationNode[] + readonly partial?: PartialAssistant | null + readonly runningCalls?: readonly RunningToolCall[] + readonly turnTimings?: LegacyConversationSlice['turnTimings'] + readonly turnEnds?: LegacyConversationSlice['turnEnds'] +} = {}, previous?: ChatSnapshot): ChatSnapshot { + const legacy: LegacyConversationSlice = { + nodes: input.nodes ?? EMPTY, + partial: input.partial ?? null, + runningCalls: input.runningCalls ?? EMPTY, + turnTimings: input.turnTimings ?? new Map(), + turnEnds: input.turnEnds ?? new Map(), + } + const turnNumbers = new Set([...legacy.turnTimings.keys(), ...legacy.turnEnds.keys()]) + for (const node of legacy.nodes) { + if ('turn' in node && typeof node.turn === 'number') turnNumbers.add(node.turn) + } + if (legacy.partial !== null) turnNumbers.add(legacy.partial.turn) + for (const call of legacy.runningCalls) turnNumbers.add(call.turn) + const turns = new Map() + const turnData = new Map() + for (const turn of [...turnNumbers].sort((left, right) => left - right)) { + const timing = legacy.turnTimings.get(turn) + const endSeq = legacy.turnEnds.get(turn) + const data = new FixtureTurnDataStore() + turnData.set(turn, data) + turns.set(turn, { + turn, + start: timing === undefined ? undefined : { + type: 'turn/start', seq: Math.max(0, (endSeq ?? 1) - 1), time: timing.startTime, turn, + } as never, + end: timing?.endTime === undefined || endSeq === undefined ? undefined : { + type: 'turn/end', seq: endSeq, time: timing.endTime, turn, reason: 'completed', + } as never, + status: endSeq === undefined ? 'open' : 'closed', + steps: EMPTY, + data, + }) + } + const linkedCompactions = new Set() + const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => { + if (node.kind === 'command' && node.name === 'compact') { + const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined + const candidates = sourceSeq === undefined + ? [] + : legacy.nodes.filter((candidate): candidate is CompactionSummaryNode => + candidate.kind === 'compaction' && candidate.summaryEventSeq === sourceSeq) + const compaction = candidates.length === 1 ? candidates[0] : undefined + if (node.outcome === null || compaction !== undefined) { + if (compaction !== undefined) linkedCompactions.add(compaction) + const base = settledNode(node, turns) + return [{ + ...base, + key: `fixture:manual-compaction:${node.commandId}`, + kind: 'manual-compaction', + anchorSeq: compaction?.seq ?? node.seq, + data: { command: node, compaction: compaction ?? null }, + }] + } + } + if (node.kind === 'compaction' && linkedCompactions.has(node)) return [] + return [settledNode(node, turns)] + }) + if (legacy.partial !== null) { + const turn = turns.get(legacy.partial.turn) + nodes.push({ + key: `fixture:assistant:${legacy.partial.turn}:${legacy.partial.step}`, + id: `${legacy.partial.turn}:${legacy.partial.step}`, + target: 'chat', + kind: 'assistant-step', + anchorSeq: Number.MAX_SAFE_INTEGER - 1, + location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn }, + visibility: 'visible', + data: { + status: 'running', + turn: legacy.partial.turn, + step: legacy.partial.step, + blocks: legacy.partial.blocks, + time: 0, + }, + }) + } + for (const call of legacy.runningCalls) { + const turn = turns.get(call.turn) + nodes.push({ + key: `fixture:tool:${call.callId}`, + id: call.callId, + target: 'chat', + kind: 'tool-call', + anchorSeq: Number.MAX_SAFE_INTEGER, + location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn }, + visibility: 'visible', + data: { root: call }, + }) + } + for (const [turnNumber, endSeq] of legacy.turnEnds) { + const turn = turns.get(turnNumber) + const dataStore = turnData.get(turnNumber) + if (turn === undefined || dataStore === undefined) continue + const closing = legacy.nodes + .filter((candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant' + && candidate.turn === turnNumber + && candidate.blocks.some(block => block.kind === 'text' && block.text.trim() !== '')) + .map(assistantData) + .at(-1) ?? null + const preceding = nodes.findLast((candidate) => { + const location = candidate.location + return (location.kind === 'turn' || location.kind === 'step') + && location.turn.turn === turnNumber + }) + const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber) + const tailData = { + turn: turnNumber, + seq: endSeq, + time: turn.end?.time ?? 0, + closing, + branchUnavailable: closing === null + || preceding?.kind !== 'assistant-step' + || (preceding.data as ReturnType).finalNode.seq !== closing.finalNode.seq, + ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs }, + ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond }, + } + dataStore.set('turn-tail', tailData) + nodes.push({ + key: `fixture:turn-tail:${turnNumber}`, + id: String(turnNumber), + target: 'chat', + kind: 'turn-tail', + anchorSeq: endSeq, + location: { kind: 'turn', turn }, + visibility: 'visible', + data: tailData, + }) + } + const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore() + store.replace(nodes) + const byKey = new Map(store.values().map(node => [node.key, node])) + const nextOrder = nodes.map(node => node.key) + const order = previous !== undefined && sameValues(previous.order, nextOrder) ? previous.order : nextOrder + const byTurn = new Map() + for (const turn of turns.keys()) { + byTurn.set(turn, order.filter((key) => { + const location = byKey.get(key)?.location + return location?.kind === 'turn' && location.turn.turn === turn + || location?.kind === 'step' && location.turn.turn === turn + })) + } + const locations = previous?.locations instanceof FixtureLocationIndex + ? previous.locations + : new FixtureLocationIndex() + locations.replace(byTurn) + const timeline = previous !== undefined + && previous.legacy.turnTimings === legacy.turnTimings + && previous.legacy.turnEnds === legacy.turnEnds + ? previous.timeline + : { turnOrder: [...turns.keys()], turns } + return { + order, + nodes: store, + locations, + timeline, + legacy, + } +} diff --git a/packages/client/ui-conversation/tests/chat-stats.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.spec.tsx index 78fbf43c5e..959a91c3ac 100644 --- a/packages/client/ui-conversation/tests/chat-stats.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.spec.tsx @@ -13,6 +13,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { en, zh } from '../src/client/locales.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: StatsLineProps['t'] = makeTranslate(zh, commonZh) @@ -42,18 +43,39 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: chatSnapshotFixture(), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } } function makeSource(init?: Partial) { - let snap: ConversationSnapshot = { ...snapshotBase(), ...init } + const initial = { ...snapshotBase(), ...init } + let snap: ConversationSnapshot = { + ...initial, + chat: init?.chat ?? chatSnapshotFixture({ + nodes: initial.nodes, + partial: initial.partial, + runningCalls: initial.runningCalls, + turnTimings: initial.turnTimings, + turnEnds: initial.turnEnds, + }), + } const subs = new Set<() => void>() return { set: (next: Partial) => { - snap = { ...snap, ...next } + const merged = { ...snap, ...next } + snap = { + ...merged, + chat: next.chat ?? (next.nodes === undefined ? snap.chat : chatSnapshotFixture({ + nodes: merged.nodes, + partial: merged.partial, + runningCalls: merged.runningCalls, + turnTimings: merged.turnTimings, + turnEnds: merged.turnEnds, + })), + } for (const fn of [...subs]) fn() }, source: { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 9bb043df15..296dd85966 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -4,24 +4,33 @@ // ObservableSnapshot fake, no wire or Tool presentation plugin. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Profiler } from 'react' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { useEffect } from 'react' import type { AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, - ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode, + ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { ChatViewSlotProps, SelectionTarget, ToolTreeOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts' +import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' +import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' +import { + CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, + UnknownNodeView, UserMessageNodeView, +} from '../src/client/chat/MessageItem.tsx' +import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' afterEach(() => { cleanup() @@ -34,10 +43,11 @@ beforeEach(() => { }) const SID = 's1' as SessionId +type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -45,11 +55,21 @@ function snapshotBase(): ConversationSnapshot { /** Scripted snapshot source: set() swaps the top-level object like the real Session. */ function makeSource(init?: Partial) { - let snap: ConversationSnapshot = { ...snapshotBase(), ...init } + const initial = { ...snapshotBase(), ...init } + let snap: ConversationSnapshot = { + ...initial, + chat: init?.chat ?? chatSnapshotFixture(initial), + } const subs = new Set<() => void>() return { set: (next: Partial) => { - snap = { ...snap, ...next } + const merged = { ...snap, ...next } + snap = { + ...merged, + chat: Object.hasOwn(next, 'chat') && next.chat !== undefined + ? next.chat + : chatSnapshotFixture(merged, snap.chat), + } for (const fn of [...subs]) fn() }, source: { @@ -73,7 +93,8 @@ const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], }) const retry = (seq: number): ModelRetryNode => ({ - kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0, + kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'], + seq, time: seq * 1_000, turn: 1, step: 0, retryState: 'scheduled', provider: 'mock', mode: 'normal', policyKey: 'mock-normal', retry: 1, maxRetries: 2, delayMs: 450, @@ -139,25 +160,97 @@ function makeHarness(init?: Partial) { // production; the view reads it through the PropsStore useStore share). const chat = createChatStore().create() const t = makeTranslate(zh, commonZh) - const toolOwners: ToolTreeOwnerProps[] = [] - const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { - if (key !== 'conversation.chat.tool') return opts?.fallback ?? null - const tool = owner as ToolTreeOwnerProps - toolOwners.push(tool) - // Tool providers own their subtree. The host double carries only the - // semantic anchor required by ChatView's prepend-position contract. - return ( -
- {tool.toolName || '(unnamed)'}:{tool.callId} -
+ const toolOwners: Array<{ + callId: string + toolName: string + block: ToolCallBlock + selectedCallId: string | undefined + openFile: ChatNodeOwnerProps['openFile'] + inspectCall: ChatNodeOwnerProps['inspectCall'] + }> = [] + const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => + opts?.fallback ?? null) as unknown as React.ComponentProps['renderSlot'] + const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as + React.ComponentProps['renderSlotChain'] + const renderTurnTailSlot = (() => null) as unknown as + React.ComponentProps['renderSlot'] + const renderSlot = ((key: string, owner: object, opts?: { + fallback?: React.ReactNode + hookContext?: unknown + }) => { + if (key !== 'conversation.chat.node') return opts?.fallback ?? null + const nodeOwner = owner as RoutedChatNodeOwner + const nodeKey = opts?.hookContext as string | undefined + const useTurnData = ((dataKey: string) => props.useSession((snapshot) => { + const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(dataKey as never) + : undefined + })) as UseChatNodeTurnData + const nodeProps = (): ChatNodeViewProps => ( + { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps ) + switch (nodeOwner.node.kind) { + case 'user': + case 'steering': + return ()} /> + case 'context': + return ()} /> + case 'assistant-step': + return ()} /> + case 'command': + return ( + ()} + renderSlot={renderCommandSlot} + SessionProvider={props.SessionProvider} + /> + ) + case 'manual-compaction': + return ()} /> + case 'compaction': + return ()} /> + case 'model-retry': + return ()} /> + case 'turn-error': + return ()} /> + case 'turn-tail': + return ( + ()} + renderSlot={renderTurnTailSlot} + renderSlotChain={renderTurnTail} + SessionProvider={props.SessionProvider} + /> + ) + case 'unknown': + return ()} /> + case 'tool-call': { + const block = (nodeOwner.node.data as { readonly root: ToolCallBlock }).root + const toolName = 'kind' in block ? block.call?.name ?? '' : block.name + const tool = { + callId: block.callId, + toolName, + block, + selectedCallId: nodeOwner.selectedCallId, + openFile: nodeOwner.openFile, + inspectCall: nodeOwner.inspectCall, + } + toolOwners.push(tool) + return ( +
+ {tool.toolName || '(unnamed)'}:{tool.callId} +
+ ) + } + default: + return opts?.fallback ?? null + } }) as unknown as ChatViewSlotProps['renderSlot'] - const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => - opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain'] // SessionProvider seat arrives with the session-scope child declaration; // ChatView never invokes it (render-prop pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)} @@ -172,7 +265,6 @@ function makeHarness(init?: Partial) { useStore: bindSnapshotSelector(chat), actions: chat.actions, renderSlot, - renderSlotChain, SessionProvider: SessionProviderStub, openDetails, openFile, @@ -218,139 +310,7 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien } } -describe('chat-flow derivation', () => { - it('groups consecutive tool results and keeps stable keys', () => { - const nodes: ConversationNode[] = [ - user(1, 'hi'), assistant(2, 'let me look'), toolResult(3, 'a'), toolResult(4, 'b'), - assistant(5, 'found'), toolResult(6, 'c'), - ] - const items = deriveChatFlow(nodes) - expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group']) - const group = items[2]! - expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b']) - expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') - expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') - }) - - it('reuses one stable row for consecutive retry turns', () => { - const first = retry(2) - const second = { ...retry(3), turn: 2, retry: 2 } - const initial = deriveChatFlow([user(1, 'try'), first]) - const updated = deriveChatFlow([user(1, 'try'), first, second]) - expect(flowKeys(initial)).toBe('n1|n2') - expect(flowKeys(updated)).toBe('n1|n2') - expect(updated).toHaveLength(2) - expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second) - }) - - it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => { - const running = command({ - seq: 1, - commandId: 'cmd-compact' as CommandNode['commandId'], - name: 'compact', - outcome: null, - }) - expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact') - - const settled = { - ...running, - outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 }, - } - const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 }) - const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint]) - expect(flowKeys(items)).toBe('n0|n2|ccmd-compact') - expect(items.at(-1)).toEqual({ - kind: 'command-compaction', - key: 'ccmd-compact', - command: settled, - compaction: checkpoint, - }) - }) - - it('does not split adjacent tool results around a folded /compact command', () => { - const folded = command({ - seq: 2, - commandId: 'cmd-compact' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 4 }, - }) - const items = deriveChatFlow([ - toolResult(1, 'a'), - folded, - toolResult(3, 'b'), - compaction({ seq: 5, summaryEventSeq: 4 }), - ]) - expect(flowKeys(items)).toBe('g1|ccmd-compact') - expect( - items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId), - ).toEqual(['a', 'b']) - }) - - it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => { - const automatic = compaction({ seq: 2, summaryEventSeq: 1 }) - expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2') - - const first = command({ - seq: 3, - commandId: 'cmd-a' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 9 }, - }) - const second = command({ - seq: 4, - commandId: 'cmd-b' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 9 }, - }) - const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 }) - expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10') - - const sole = command({ - seq: 11, - commandId: 'cmd-sole' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 12 }, - }) - const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 }) - const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 }) - expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14') - }) - - it('skips render-nothing assistant nodes so tool runs stay one group', () => { - // A tool-call-only step message (and blank text/reasoning) renders nothing: - // it must not split the run into two groups with an empty line between. - const headsOnly: AssistantMessageNode = { - kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, - blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }], - } - const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')]) - expect(flowKeys(items)).toBe('g3') - const group = items[0]! - expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b']) - // Interrupted and visible-content nodes still render (已停止 marker / prose). - expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5') - expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') - }) - - it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => { - const thinkOnly: AssistantMessageNode = { - kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2, - blocks: [{ kind: 'reasoning', text: 'planning' }], - } - const nodes: ConversationNode[] = [ - user(1, 'hi'), - assistant(2, 'looking', 1), - thinkOnly, - toolResult(4, 'a'), - assistant(5, 'done', 1), - user(6, 'again'), - assistant(7, 'second turn', 2), - ] - expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7]) - // Turn 2 is still producing steps: its latest narration owns nothing, and - // the settled turn 1 keeps its seat. - expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5]) - }) +describe('Chat node rendering', () => { it('threads the injected file-mention vocabulary into the closing prose only', () => { const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ @@ -391,17 +351,6 @@ describe('chat-flow derivation', () => { expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html') }) - it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { - expect(runningTurnStartTime(new Map([ - [1, { startTime: 1_000, endTime: 5_000 }], - [2, { startTime: 6_000 }], - ]))).toBe(6_000) - expect(runningTurnStartTime(new Map([ - [1, { startTime: 1_000, endTime: 5_000 }], - [2, { startTime: 6_000, endTime: 9_000 }], - ]))).toBeNull() - }) - it('formatRunDuration localizes units and floors partial seconds', () => { const t = makeTranslate(zh, commonZh) expect(formatRunDuration(0, t)).toBe('0秒') @@ -410,24 +359,6 @@ describe('chat-flow derivation', () => { expect(formatRunDuration(125_000, t)).toBe('2分05秒') }) - it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => { - const interruptedThink: AssistantMessageNode = { - kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, - blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, - } - const nodes: ConversationNode[] = [ - user(1, 'first'), - assistant(2, 'answer before tools'), - toolResult(3, 'a'), - interruptedThink, - user(6, 'second'), - assistant(7, 'clean tail', 2), - user(10, 'user-only tail'), - user(13, 'steering tail'), - ] - const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]])) - expect([...seqs]).toEqual([7]) - }) }) describe('ChatView', () => { @@ -444,8 +375,8 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement - const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement - const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement + const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement + const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement let firstTop = 100 let nextTop = 300 vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation( @@ -472,7 +403,7 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift }) - it('renders the fixture main line: bubble, narration, grouped tool rows', () => { + it('renders the fixture main line as independently keyed business nodes', () => { const h = makeHarness({ nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')], }) @@ -485,14 +416,18 @@ describe('ChatView', () => { key: row.getAttribute('data-chat-flow-key'), kind: row.getAttribute('data-chat-flow-kind'), }))).toEqual([ - { key: 'n1', kind: 'user' }, - { key: 'n2', kind: 'assistant' }, - { key: 'g3', kind: 'tool-group' }, + { key: 'fixture:user:1', kind: 'user' }, + { key: 'fixture:assistant:2', kind: 'assistant-step' }, + { key: 'fixture:tool:a', kind: 'tool-call' }, + { key: 'fixture:tool:b', kind: 'tool-call' }, ]) expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id'))) .toEqual(['a', 'b']) expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key'))) - .toEqual(['node:1', 'node:2', 'call:a', 'call:b']) + .toEqual([ + 'fixture:user:1', 'fixture:assistant:2', + 'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b', + ]) }) it('renders Host-pending steering at the flow tail and hands off to the durable node', () => { @@ -559,14 +494,13 @@ describe('ChatView', () => { act(() => { h.set({ running: false, turnEnds: new Map([[1, 3]]) }) }) - // The completed turn's transcript tail is the steering bubble, not the - // narration, so the assistant's branch action stays unavailable and the - // steering bubble still offers none. + // The Turn Tail belongs to the closed Turn, independently of a later + // steering bubble's placement in the Chat list. const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) expect(branchButtons).toHaveLength(1) - expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true') + expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull() fireEvent.click(branchButtons[0]!) - expect(h.forkAt).not.toHaveBeenCalled() + expect(h.forkAt).toHaveBeenCalledWith(1) }) it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => { @@ -607,7 +541,7 @@ describe('ChatView', () => { expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') act(() => { - h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] }) + h.set({ nodes: [user(1, 'try'), nextRetry] }) }) expect(within(disclosure).getAllByRole('status')).toHaveLength(1) expect(view.container.querySelector('details')).toBe(disclosure) @@ -617,7 +551,6 @@ describe('ChatView', () => { h.set({ nodes: [ user(1, 'try'), - retryNode, { ...nextRetry, retryState: 'started' }, context, assistant(5, 'done'), @@ -760,7 +693,7 @@ describe('ChatView', () => { turnEnds: new Map([[1, 2]]), }) const view = render() - // One scope per message row; the CSS reveal keys off this attribute. + // The user row and the settled assistant's Turn Tail each own one clock scope. expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2) }) @@ -787,7 +720,7 @@ describe('ChatView', () => { expect(h.forkAt.mock.calls).toEqual([[2]]) }) - it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => { + it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => { const interruptedThink: AssistantMessageNode = { kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, @@ -843,19 +776,13 @@ describe('ChatView', () => { expect(view.container.querySelectorAll('h1')).toHaveLength(2) }) - it('streaming partial frames re-render only the tail (Profiler count)', () => { + it('streaming partial frames update the tail without replacing a sibling Tool row', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')], }) - let renders = 0 - const counting = ( - { renders += 1 }}> - - - ) - const view = render(counting) - const before = renders - const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML + const view = render() + const tool = view.getByTestId('tool-seat-a') + const beforeHtml = tool.innerHTML act(() => { h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } }) }) @@ -863,9 +790,8 @@ describe('ChatView', () => { h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } }) }) expect(view.getByText('streaming… more')).toBeTruthy() - // Each chunk commits exactly one profiler pass (the tail), never a full-tree storm. - expect(renders - before).toBe(2) - expect(view.container.querySelector('[class*="toolGroup"]')!.innerHTML).toBe(beforeHtml) + expect(view.getByTestId('tool-seat-a')).toBe(tool) + expect(tool.innerHTML).toBe(beforeHtml) }) it('streaming leaves neighbor tool rows and history items at zero re-renders', () => { @@ -875,8 +801,9 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = ((key: string, _owner: object) => { - if (key !== 'conversation.chat.tool') return null + h.props.renderSlot = ((key: string, owner: object) => { + if (key !== 'conversation.chat.node' + || (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null rowRenders += 1 return
}) @@ -908,6 +835,54 @@ describe('ChatView', () => { expect(view.getByRole('status').textContent).toBe('Deep diving...') }) + it('keeps the Tool renderer mounted when a running call settles into log order', () => { + const mounted = vi.fn() + const unmounted = vi.fn() + function StatefulToolNode({ node }: { readonly node: ChatNode<'tool-call'> }) { + useEffect(() => { + mounted() + return () => { unmounted() } + }, []) + const root = (node.data as { readonly root: ToolCallBlock }).root + return ( +
+ {root.callId} +
+ ) + } + + const h = makeHarness({ + nodes: [user(1, 'q'), assistant(4, 'later')], + runningCalls: [runningCall('r1')], + running: true, + }) + h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + const routed = owner as RoutedChatNodeOwner + return key === 'conversation.chat.node' && routed.node.kind === 'tool-call' + ? + : opts?.fallback ?? null + }) as ChatViewSlotProps['renderSlot'] + const view = render() + const tool = view.getByTestId('stateful-tool') + const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]') + expect(tool.dataset.state).toBe('running') + expect(mounted).toHaveBeenCalledTimes(1) + + act(() => { + h.set({ + nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')], + runningCalls: [], + running: false, + }) + }) + + expect(view.getByTestId('stateful-tool')).toBe(tool) + expect(view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')).toBe(row) + expect(tool.dataset.state).toBe('settled') + expect(mounted).toHaveBeenCalledTimes(1) + expect(unmounted).not.toHaveBeenCalled() + }) + it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => { const startTime = Date.now() - 125_000 const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 } @@ -932,7 +907,7 @@ describe('ChatView', () => { expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/) }) - it('hands each ordered root call to the whole-Tool slot', () => { + it('hands each ordered root call to the keyed business-node slot', () => { const block = toolResult(3, 'a') const h = makeHarness({ nodes: [block] }) const calls: { key: string; owner: object; entryKey?: string }[] = [] @@ -943,14 +918,14 @@ describe('ChatView', () => { render() expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ - key: 'conversation.chat.tool', - owner: { callId: 'a', toolName: 'bash', selectedCallId: undefined }, + key: 'conversation.chat.node', + owner: { node: { kind: 'tool-call' }, selectedCallId: undefined }, + entryKey: 'tool-call', }) - const owner = calls[0]?.owner as ToolTreeOwnerProps - expect(owner.block).toBe(block) + const owner = calls[0]?.owner as RoutedChatNodeOwner + expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block) expect(owner.openFile).toBe(h.openFile) expect(owner.inspectCall).toBe(h.inspectCall) - expect(calls[0]?.entryKey).toBeUndefined() }) it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { @@ -960,7 +935,7 @@ describe('ChatView', () => { // jsdom has no layout: fake the metrics the anchor math reads. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true }) - const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement + const anchored = view.container.querySelector('[data-chat-flow-key="fixture:user:5"]') as HTMLDivElement let anchoredTop = 100 vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation( () => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect), @@ -977,60 +952,6 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(1600) }) - it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => { - const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true }) - const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement - let prepended = false - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'call:late') { - const top = prepended ? 400 : 100 - return { top, bottom: top + 40 } as DOMRect - } - return { top: 0, bottom: 200 } as DOMRect - }) - try { - Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) - Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) - readerScroll(scroller, 80) - fireEvent.click(view.getByText('加载更早')) - // Total height grows by 500, but only 300 belongs before the call row. - Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) - prepended = true - act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) }) - expect(scroller.scrollTop).toBe(380) - } finally { - rect.mockRestore() - } - }) - - it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => { - const h = makeHarness({ nodes: [retry(5)], hasMore: true }) - const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement - let prepended = false - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'node:5') { - const top = prepended ? 400 : 100 - return { top, bottom: top + 40 } as DOMRect - } - return { top: 0, bottom: 200 } as DOMRect - }) - try { - Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) - Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) - readerScroll(scroller, 80) - fireEvent.click(view.getByText('加载更早')) - Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) - prepended = true - act(() => { h.set({ nodes: [retry(4), retry(5)] }) }) - expect(scroller.scrollTop).toBe(380) - expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull() - } finally { - rect.mockRestore() - } - }) - it('back-to-bottom cancels an in-flight paging anchor', () => { const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true }) const view = render() @@ -1177,7 +1098,7 @@ describe('ChatView', () => { () => ({ top: 0, bottom: 500 } as DOMRect), ) const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'node:1') { + if (this.dataset.chatAnchorKey === 'fixture:user:1') { return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect } return { top: 0, bottom: 40 } as DOMRect @@ -1216,12 +1137,12 @@ describe('ChatView', () => { }) document.body.appendChild(host) const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect + if (this.dataset.chatAnchorKey === 'fixture:user:1') return { top: 300, bottom: 340 } as DOMRect return { top: 0, bottom: 500 } as DOMRect }) try { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) - h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 }) + h.chatScroll.save({ anchorKey: 'fixture:user:1', anchorTop: 80, scrollTop: 1_400 }) const view = render(, { container: host }) expect(host.scrollTop).toBe(1_500) expect(h.chatScroll.read()).toBeNull() diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts new file mode 100644 index 0000000000..3ae906087e --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -0,0 +1,720 @@ +import { describe, expect, it } from 'vitest' +import type { + ChatConversationViewNode, ChatSnapshot, ConversationEventInput, + ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' +import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' +import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { commandDefinition } from '../src/client/conversation-nodes/command.ts' +import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts' +import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts' +import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' +import { messageDefinition } from '../src/client/conversation-nodes/message.ts' +import { retryDefinition } from '../src/client/conversation-nodes/retry.ts' +import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' +import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' +import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts' +import type { + AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, +} from '../src/client/contract/chat-nodes.ts' + +const DEFINITIONS: readonly ConversationNodeDefinition[] = [ + nextTurnInboxDefinition, + nextStepInboxDefinition, + messageDefinition, + assistantDefinition, + toolDefinition, + commandDefinition, + compactionDefinition, + retryDefinition, + turnErrorDefinition, + turnTailDefinition, +] + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { + return DEFINITIONS + } + + fallbackEntry(): ConversationNodeDefinition { + return unknownFallbackDefinition + } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { + return [chatViewDefinition] + } +} + +function at( + seq: number, + type: string, + data: unknown, + extra: Record = {}, +): ConversationEventInput { + return { + event: { + seq, + time: 1_700_000_000_000 + seq, + type, + data, + ...extra, + } as unknown as ConversationEventInput['event'], + view: undefined, + } +} + +function assembler(entries: readonly ConversationEventInput[] = [], hasMore = false): ConversationNodeAssembler { + const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + value.replaceWindow(entries, hasMore) + value.flush() + return value +} + +function snapshot(value: ConversationNodeAssembler): ChatSnapshot { + const current = value.snapshot('chat') as ChatSnapshot | undefined + if (current === undefined) throw new Error('chat view was not registered') + return current +} + +function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined { + return value.nodes.values().find(candidate => candidate.kind === kind) +} + +function textMessage(id: string, text: string) { + return { + id, + role: 'user', + content: [{ type: 'text', text }], + source: { kind: 'user' }, + } +} + +function assistantMessage(id: string, text: string) { + return { + id, + role: 'assistant', + content: [{ type: 'text', text }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + } +} + +function toolResult(callId: string, text: string) { + return { + id: `result-${callId}`, + role: 'user', + source: { kind: 'tool', callId }, + content: [{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text }], + isError: false, + }], + } +} + +describe('built-in conversation node Definitions', () => { + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'streaming' }, + }), + ]) + const runningSnapshot = snapshot(value) + const running = node(runningSnapshot, 'assistant-step') + expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] }) + const order = runningSnapshot.order + + value.append(at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-1', 'settled'), + }, { surfaceOp: 'append' })) + value.flush() + + const settledSnapshot = snapshot(value) + const settled = node(settledSnapshot, 'assistant-step') + expect(settled?.key).toBe(running?.key) + expect(settledSnapshot.order).toBe(order) + expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] }) + + const interruptedValue = assembler([ + at(10, 'turn/start', { turn: 2 }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(12, 'assistant/chunk', { + turn: 2, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'partial' }, + }), + at(13, 'step/end', { turn: 2, step: 1 }), + ]) + const interrupted = node(snapshot(interruptedValue), 'assistant-step') + expect(interrupted?.data).toMatchObject({ status: 'interrupted' }) + expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true) + + const hiddenValue = assembler([ + at(20, 'turn/start', { turn: 3 }), + at(21, 'step/start', { turn: 3, step: 1 }), + at(22, 'llm/retry', { + retryId: 'retry-hidden', + turn: 3, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + }), + ]) + expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined() + + const toolOnlyValue = assembler([ + at(30, 'turn/start', { turn: 4 }), + at(31, 'step/start', { turn: 4, step: 1 }), + at(32, 'assistant/chunk', { + turn: 4, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' }, + }), + at(33, 'assistant/message', { + turn: 4, + step: 1, + message: { + ...assistantMessage('assistant-tool-only', ''), + content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }], + }, + }, { surfaceOp: 'append' }), + ]) + const toolOnlySnapshot = snapshot(toolOnlyValue) + expect(toolOnlySnapshot.order).toEqual([]) + expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden') + expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{ + kind: 'assistant', + seq: 33, + timing: { firstTokenTime: 1_700_000_000_032 }, + }]) + + const interruptedToolOnlyValue = assembler([ + at(35, 'turn/start', { turn: 5 }), + at(36, 'step/start', { turn: 5, step: 1 }), + at(37, 'assistant/chunk', { + turn: 5, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' }, + }), + at(38, 'step/end', { turn: 5, step: 1 }), + ]) + const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step') + expect(interruptedToolOnly?.visibility).toBe('visible') + expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' }) + + const retryTimingValue = assembler([ + at(50, 'turn/start', { turn: 6 }), + at(51, 'step/start', { turn: 6, step: 1 }), + at(52, 'assistant/chunk', { + turn: 6, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'first attempt' }, + }), + at(53, 'llm/retry', { + retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal', + policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + }), + at(54, 'assistant/chunk', { + turn: 6, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'second attempt' }, + }), + at(55, 'assistant/message', { + turn: 6, + step: 1, + message: assistantMessage('assistant-retried', 'done'), + }, { surfaceOp: 'append' }), + ]) + const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode + expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052) + + const partialWindow = assembler([ + at(40, 'assistant/chunk', { + turn: 5, + step: 2, + chunk: { type: 'text-delta', index: 0, text: 'loaded partial' }, + }), + at(41, 'step/end', { turn: 5, step: 2 }), + ], true) + const recovered = node(snapshot(partialWindow), 'assistant-step') + expect(recovered?.data).toMatchObject({ + status: 'interrupted', + blocks: [{ kind: 'text', text: 'loaded partial' }], + }) + }) + + it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }), + ]) + const runningSnapshot = snapshot(value) + const running = node(runningSnapshot, 'tool-call') + expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' }) + const order = runningSnapshot.order + + value.append(at(4, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('root', 'done'), + }, { surfaceOp: 'append' })) + value.flush() + + const settledSnapshot = snapshot(value) + const settled = node(settledSnapshot, 'tool-call') + expect(settled?.key).toBe(running?.key) + expect(settledSnapshot.order).toBe(order) + expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' }) + + const history = assembler([ + at(14, 'tool/code-dispatch-start', { + rootCallId: 'history-root', + parentCallId: 'history-root', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + }), + at(15, 'tool/code-dispatch', { + rootCallId: 'history-root', + parentCallId: 'history-root', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + isError: false, + content: [{ type: 'text', text: 'contents' }], + }), + at(16, 'tool/result', { + turn: 2, + step: 1, + message: toolResult('history-root', 'root done'), + }, { surfaceOp: 'append' }), + ], true) + const before = node(snapshot(history), 'tool-call') + expect((before?.data as ToolChatData).root.subCalls).toMatchObject([ + { kind: 'tool-result', callId: 'child', call: { name: 'read' } }, + ]) + + history.prepend([ + at(10, 'turn/start', { turn: 2 }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(13, 'tool/call', { + turn: 2, + step: 1, + callId: 'history-root', + name: 'code', + arguments: '{}', + }), + ], false) + history.flush() + + const after = node(snapshot(history), 'tool-call') + expect(after?.key).toBe(before?.key) + expect((after?.data as ToolChatData).root.subCalls).toMatchObject([ + { kind: 'tool-result', callId: 'child', call: { name: 'read' } }, + ]) + + const firstChild = (after?.data as ToolChatData).root.subCalls[0] + history.append(at(17, 'tool/code-dispatch-start', { + rootCallId: 'history-root', + parentCallId: 'history-root', + subCallId: 'second-child', + name: 'write', + arguments: { path: 'out.txt' }, + })) + history.flush() + const withSecondChild = node(snapshot(history), 'tool-call') + expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild) + }) + + it('prepends an older turn without replacing already materialized nodes', () => { + const value = assembler([ + at(20, 'turn/start', { turn: 2 }), + at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }), + at(22, 'step/start', { turn: 2, step: 1 }), + at(23, 'assistant/message', { + turn: 2, + step: 1, + message: assistantMessage('newer-assistant', 'newer answer'), + }, { surfaceOp: 'append' }), + at(24, 'step/end', { turn: 2, step: 1 }), + at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }), + ], true) + const before = snapshot(value) + const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '') + const store = before.nodes + + value.prepend([ + at(10, 'turn/start', { turn: 1 }), + at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }), + at(12, 'step/start', { turn: 1, step: 1 }), + at(13, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('older-assistant', 'older answer'), + }, { surfaceOp: 'append' }), + at(14, 'step/end', { turn: 1, step: 1 }), + at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ], false) + value.flush() + + const after = snapshot(value) + expect(after.nodes).toBe(store) + expect(after.nodes.get(existing?.key ?? '')).toBe(existing) + expect(after.order).toHaveLength(before.order.length + 3) + expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ + 'user', 'assistant-step', 'turn-tail', + 'user', 'assistant-step', 'turn-tail', + ]) + }) + + it('appends a later turn without replacing nodes from the completed turn', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('first-assistant', 'first answer'), + }, { surfaceOp: 'append' }), + at(5, 'step/end', { turn: 1, step: 1 }), + at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + const before = snapshot(value) + const oldOrder = before.order + const oldNodes = oldOrder.map(key => before.nodes.get(key)) + + value.append(at(7, 'turn/start', { turn: 2 })) + value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' })) + value.flush() + + const after = snapshot(value) + expect(after.nodes).toBe(before.nodes) + expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder) + expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes) + expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ + 'user', 'assistant-step', 'turn-tail', 'user', + ]) + }) + + it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => { + const value = assembler([ + at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }), + ], true) + const before = node(snapshot(value), 'user') + expect(before).toBeDefined() + + value.prepend([ + at(1, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + inserted: [textMessage('steer-1', 'change direction')], + }), + at(2, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + removedCount: 1, + inserted: [], + }), + ], false) + value.flush() + + const after = node(snapshot(value), 'steering') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' }) + expect(node(snapshot(value), 'user')).toBeUndefined() + }) + + it('classifies appended producer context from durable source metadata', () => { + const value = assembler([ + at(1, 'user/message', { + ...textMessage('skill-context', 'follow these instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }, { surfaceOp: 'append' }), + ]) + + expect(node(snapshot(value), 'context')?.data).toMatchObject({ + kind: 'context', + provenance: { role: 'inject', label: 'demo-skill' }, + form: 'instructions', + }) + }) + + it('keeps replacement copies out of Chat business nodes', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', { + ...textMessage('replacement-user', 'model-only context'), + source: { kind: 'plugin', plugin: 'foreign' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 1 } }), + at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('replacement-assistant', 'rewritten answer'), + }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }), + at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }), + at(6, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('root', 'pruned result'), + }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }), + ]) + + const current = snapshot(value) + expect(node(current, 'user')).toBeUndefined() + expect(node(current, 'context')).toBeUndefined() + expect(node(current, 'assistant-step')).toBeUndefined() + expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind') + }) + + it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => { + const retry = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'llm/retry', { + retryId: 'retry-1', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'first' }, + }), + at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }), + at(5, 'llm/retry', { + retryId: 'retry-1', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 2, + maxRetries: 2, + delayMs: 20, + failure: { code: 'TRANSPORT', message: 'second' }, + }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } }, + }), + ]) + const retryNode = node(snapshot(retry), 'model-retry') + const retryData = retryNode?.data as RetryChatData + expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled']) + expect(node(snapshot(retry), 'turn-error')).toBeUndefined() + + const compactions = assembler([ + at(10, 'command/run', { + commandId: 'command-1', + name: 'compact', + source: { kind: 'user' }, + }), + at(11, 'compact/start', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + turn: null, + }), + at(12, 'compact/summary', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + summary: [{ type: 'text', text: 'manual summary' }], + shadowedSeqs: [1, 2], + shadowedTokenCount: 100, + }), + at(13, 'user/message', { + ...textMessage('manual-checkpoint', 'checkpoint'), + source: { + kind: 'plugin', + plugin: 'compact', + compactionId: 'manual-1', + sourceCommandId: 'command-1', + }, + }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }), + at(14, 'compact/end', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + turn: null, + }), + at(15, 'command/done', { + commandId: 'command-1', + kind: 'success', + sourceEventSeq: 12, + }), + at(20, 'compact/start', { compactionId: 'automatic-1', turn: null }), + at(21, 'compact/summary', { + compactionId: 'automatic-1', + summary: [{ type: 'text', text: 'automatic summary' }], + shadowedSeqs: [3, 4], + shadowedTokenCount: 200, + }), + at(22, 'user/message', { + ...textMessage('automatic-checkpoint', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' }, + }, { surfaceOp: { op: 'replace', start: 3, end: 4 } }), + at(23, 'compact/end', { compactionId: 'automatic-1', turn: null }), + ]) + + const manual = node(snapshot(compactions), 'manual-compaction') + expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({ + summary: 'manual summary', + summaryEventSeq: 12, + }) + const automatic = node(snapshot(compactions), 'compaction') + expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 }) + expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1) + }) + + it('fills a landed compaction marker when an older page supplies its summary', () => { + const value = assembler([ + at(13, 'user/message', { + ...textMessage('checkpoint', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 8 } }), + ], true) + const before = node(snapshot(value), 'compaction') + expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null }) + + value.prepend([ + at(9, 'compact/start', { compactionId: 'compact-1', turn: null }), + at(10, 'compact/summary', { + compactionId: 'compact-1', + summary: [ + { type: 'text', text: 'older ' }, + { type: 'image', data: 'ignored' }, + { type: 'text', text: 'summary' }, + ], + shadowedSeqs: [1, 2, 3], + shadowedTokenCount: 42, + }), + ], false) + value.flush() + + const after = node(snapshot(value), 'compaction') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ + summary: 'older summary', + summaryEventSeq: 10, + shadowedItemCount: 3, + shadowedTokenCount: 42, + }) + }) + + it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => { + const value = assembler([ + at(5, 'llm/retry', { + retryId: 'retry-paged', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 2, + maxRetries: 2, + delayMs: 20, + failure: { code: 'TRANSPORT', message: 'second' }, + }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } }, + }), + ], true) + + expect(node(snapshot(value), 'model-retry')).toBeUndefined() + expect(node(snapshot(value), 'turn-error')).toBeUndefined() + + value.prepend([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'llm/retry', { + retryId: 'retry-paged', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'first' }, + }), + at(4, 'llm/retry-started', { + retryId: 'retry-paged', turn: 1, step: 1, retry: 1, + }), + ], false) + value.flush() + + const retry = node(snapshot(value), 'model-retry') + expect((retry?.data as RetryChatData).attempts).toHaveLength(2) + expect(node(snapshot(value), 'turn-error')).toBeUndefined() + }) + + it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => { + const value = assembler([ + at(12, 'tool/code-dispatch-start', { + rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' }, + }), + at(13, 'tool/code-dispatch', { + rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' }, + isError: false, content: [{ type: 'text', text: 'child result' }], + }), + at(14, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('root', 'root result'), + }, { surfaceOp: 'append' }), + at(20, 'compact/summary', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + summary: [{ type: 'text', text: 'manual summary' }], + shadowedSeqs: [1, 2], + shadowedTokenCount: 100, + }), + at(21, 'user/message', { + ...textMessage('manual-checkpoint', 'checkpoint'), + source: { + kind: 'plugin', + plugin: 'compact', + compactionId: 'manual-1', + sourceCommandId: 'command-1', + }, + }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }), + at(22, 'command/done', { + commandId: 'command-1', + kind: 'success', + sourceEventSeq: 20, + }), + ], true) + + const tool = node(snapshot(value), 'tool-call') + const root = (tool?.data as ToolChatData).root + expect(root.subCalls).toHaveLength(1) + expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' }) + const manual = node(snapshot(value), 'manual-compaction') + expect((manual?.data as ManualCompactionChatData)).toMatchObject({ + command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } }, + compaction: { summary: 'manual summary', summaryEventSeq: 20 }, + }) + }) +}) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3afe471d50..ff3a0f1598 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots' @@ -15,6 +15,7 @@ import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/ch import { StatsLine } from '../src/client/chat/StatsLine.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' import { zh } from '../src/client/locales.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) @@ -40,14 +41,15 @@ const SessionProviderStub: SessionProviderComponent = ({ children }) => children /** Observe the owner currency without importing the Tool details renderer. */ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] { return (_key, owner) => { - owners?.push(owner as DetailsToolOwnerProps) + owners?.push(owner as unknown as DetailsToolOwnerProps) return
} } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -69,11 +71,16 @@ describe('render branch tails', () => { it('StatsLine counts window nodes but drops every token group without a projection', () => { // Node `usage` is deliberately ignored: billing rides the durable // tokenUsage projection, so an absent projection leaves counts only. + const nodes = [ + { kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] }, + { kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } }, + { kind: 'assistant', seq: 3, time: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } }, + ] as const const snap = { + ...snapshotBase(), + chat: chatSnapshotFixture({ nodes }), nodes: [ - { kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] }, - { kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } }, - { kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } }, + ...nodes, ], } const source = { getSnapshot: () => snap, subscribe: () => () => {} } @@ -146,6 +153,7 @@ describe('render branch tails', () => { }], }], }] + snap.chat = chatSnapshotFixture({ runningCalls: snap.runningCalls }) const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 1e7c3a1ba1..e9076fa994 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -35,7 +35,8 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f197528a2b..65fa5705ca 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -26,7 +26,8 @@ const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index aae5bb4818..94d348d17a 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -11,7 +11,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' @@ -112,7 +112,8 @@ async function scopedBench(register?: (slash: SlashService) => void) { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell const sessionStore = createSnapshotStore({ - sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 826abaf846..31a6423f87 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -32,7 +33,8 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 70b6d96710..bbabbaeb0c 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' @@ -70,7 +70,8 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 3bcaffe605..b7ca73a79f 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -8,15 +8,22 @@ import { Context } from 'cordis' import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationNodeAssembler, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' import type { - AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, + ConversationEventInput, ConversationLocationDataStore, ConversationNodeDefinition, + ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, + ConversationViewNode, ToolResultNode, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' -import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' +import { + basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, + type DeliverablesTurnData, +} from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -24,101 +31,187 @@ import { zh } from '../src/client/locales.ts' afterEach(cleanup) -const user = (seq: number, text: string): UserMessageNode => ({ - kind: 'user', - seq, - time: seq * 1000, - content: [{ type: 'text', text }] as never, - source: null, +class TestTurnDataStore implements ConversationLocationDataStore { + private readonly values = new Map() + + get( + key: Key, + ): Readonly | undefined { + return this.values.get(key) as Readonly | undefined + } + + set(key: Key, value: ConversationTurnDataMap[Key]): void { + this.values.set(key, value) + } +} + +const turnLocation = (turn: number, deliverables?: DeliverablesTurnData): TurnLocation => { + const data = new TestTurnDataStore() + if (deliverables !== undefined) data.set('deliverables', deliverables) + return { turn, start: undefined, end: undefined, status: 'closed', steps: [], data } +} + +const produced = (...values: ReadonlyArray): DeliverablesTurnData => ({ + produced: values.map(([seq, path]) => ({ seq, path })), }) -const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ - kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], -}) -const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ - kind: 'tool-result', seq, time: seq * 1_000, callId, - call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, - callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, subCalls: [], -}) -const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { + +function tailOwner( + data: DeliverablesTurnData | undefined, + seq: number, + openFile: (path: string) => void = () => {}, + turn = 1, +): TurnTailOwnerProps { + return { seq, openFile, turn: turnLocation(turn, data) } +} + +interface TimelineSnapshot { + readonly timeline: ConversationTimelineSnapshot +} + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] } + fallbackEntry(): undefined { return undefined } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { return [timelineViewDefinition] } +} + +const timelineViewDefinition: ConversationViewDefinition = { + target: 'test', + create: () => { + let current: TimelineSnapshot = { timeline: { turnOrder: [], turns: new Map() } } + return { + empty: current, + replace: ({ timeline }) => (current = { timeline }), + apply: ({ timeline }) => (current = { timeline }), + } + }, +} + +function at( + seq: number, + type: string, + data: unknown, + view?: ConversationEventInput['view'], +): ConversationEventInput { + return { + event: { + seq, time: seq * 1_000, type, data, + ...(type === 'tool/result' ? { surfaceOp: 'append' } : {}), + } as ConversationEventInput['event'], + view, + } +} + +function call( + seq: number, + callId: string, + view: ToolResultNode['callView'], + turn = 1, +): ConversationEventInput { + return at( + seq, + 'tool/call', + { turn, step: 1, callId, name: 'fixture', arguments: '{}' }, + { for: 'call', view: view ?? { card: 'generic', title: 'fixture' } }, + ) +} + +function result(seq: number, callId: string, isError = false, turn = 1): ConversationEventInput { + return at(seq, 'tool/result', { + turn, + step: 1, + message: { + source: { type: 'tool-result', callId }, + content: [{ type: 'tool-result', content: [], isError }], + }, + }) +} + +function diff(...paths: string[]): ToolResultNode['callView'] { + return { card: 'diff', title: `Write ${paths[0] ?? ''}`, diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), locations: paths.map(path => ({ path })), - }, -}) + } +} -describe('producedForClosing derivation', () => { - it('attributes each turn’s written files to the assistant that closes it', () => { - const nodes: ConversationNode[] = [ - user(1, 'build it'), - assistant(2, 'writing', 1), - wrote(3, 'a', 'out/index.html'), - // Same file touched twice in one turn is one deliverable, in first-seen order. - wrote(4, 'b', 'out/app.css', 'out/index.html'), - // A read is not a deliverable; a failed write has no file to open. - { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, - { ...wrote(6, 'd', 'out/broken.html'), isError: true }, - assistant(7, 'done', 1), - user(8, 'again'), - assistant(9, 'second turn', 2), - ] - expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) - expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css']) - expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull() - // A turn that produced nothing yields the empty list, and so does an - // anchor the window does not contain. - expect(producedForClosing(nodes, 9)).toEqual([]) - expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([]) - expect(producedForClosing(nodes, 999)).toEqual([]) +function edit(path: string): ToolResultNode['callView'] { + return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] } +} + +function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler { + const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + value.replaceWindow(entries, hasMore) + value.flush() + return value +} + +function deliverablesOf(value: ConversationNodeAssembler, turn = 1): Readonly | undefined { + const snapshot = value.snapshot('test') as TimelineSnapshot + return snapshot.timeline.turns.get(turn)?.data.get('deliverables') +} + +describe('produced-file Turn data', () => { + it('deduplicates paths in first-seen order and stops at the closing Assistant seq', () => { + const data = produced( + [3, 'out/index.html'], + [4, 'out/app.css'], + [4, 'out/index.html'], + [8, 'after.txt'], + ) + expect(producedForClosing(data, 6)).toEqual(['out/index.html', 'out/app.css']) + expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css']) + expect(producedForClosing(undefined)).toEqual([]) + expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull() }) + it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(2, 'write', diff('out/index.html', 'out/app.css')), + result(3, 'write'), + call(4, 'edit', edit('notes.md')), + result(5, 'edit'), + call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }), + result(7, 'read'), + call(8, 'failed', diff('broken.txt')), + result(9, 'failed', true), + call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }), + result(11, 'locationless'), + ]) - it('counts a generic edit and never spills across the turn boundary', () => { - const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'str_replace_editor'), - // str_replace_editor's insert mutates behind a generic card, so the - // discriminant is the render intent, not the card shape alone. - callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, - }) - const nodes: ConversationNode[] = [ - user(1, 'insert a line'), - inserted(2, 'i', 'notes.md'), - assistant(3, 'inserted', 1), - // Turn 2 mutates and then ends with no content text (interrupted, or its - // last text preceded the tool): its paths must not ride into turn 3. - user(4, 'now rewrite it'), - wrote(5, 'w', 'leaked.txt'), - user(6, 'and again'), - wrote(7, 'w2', 'notes.md'), - assistant(8, 'done', 3), - ] - expect(producedForClosing(nodes, 3)).toEqual(['notes.md']) - // Turn 3 lists only its own file — and the dedup set did not suppress the - // rewrite of a path an earlier turn already touched. - expect(producedForClosing(nodes, 8)).toEqual(['notes.md']) - expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt') + expect(producedForClosing(deliverablesOf(value))).toEqual([ + 'out/index.html', 'out/app.css', 'notes.md', + ]) }) - it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => { - const nodes: ConversationNode[] = [ - user(1, 'go'), - // A turnless surface node neither tracks nor resets the boundary. - { kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null }, - wrote(2, 'w', 'turn-one.txt'), - // A view-less result (window truncation) and cards without locations - // contribute nothing rather than crashing the walk. - toolResult(3, 'plain'), - { ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } }, - { ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } }, - assistant(6, 'mid narration', 1), - // Turn number advances with no user message in the window (truncated - // history): the accumulator must reset all the same. - assistant(7, 'closing', 2), - ] - expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt']) - expect(producedForClosing(nodes, 7)).toEqual([]) + it('replays a tail page once prepend supplies its missing Turn start', () => { + const value = assembler([ + call(10, 'late', diff('history.txt')), + result(11, 'late'), + ], true) + expect(deliverablesOf(value)).toBeUndefined() + + value.prepend([at(1, 'turn/start', { turn: 1 })], false) + value.flush() + expect(producedForClosing(deliverablesOf(value))).toEqual(['history.txt']) + }) + + it('extends the same Turn data incrementally on live append', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(2, 'first', diff('first.txt')), + result(3, 'first'), + ]) + const first = deliverablesOf(value) + expect(producedForClosing(first)).toEqual(['first.txt']) + + value.append(call(4, 'second', diff('second.txt'))) + value.append(result(5, 'second')) + value.flush() + expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt']) }) }) @@ -190,6 +283,7 @@ describe('plugin registration', () => { it('registers the tail entry and fiber disposal removes it', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() + await ctx.plugin(ConversationEventRegistry).await() // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', @@ -204,17 +298,17 @@ describe('plugin registration', () => { // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. const opened: string[] = [] - const owner = { - nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)], - seq: 3, - openFile: (path: string) => { opened.push(path) }, - } + const owner = tailOwner( + produced([2, 'site/report.html']), + 3, + (path) => { opened.push(path) }, + ) const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions') const mentions = service?.forClosing(owner) mentions?.resolve('report.html')?.open() expect(opened).toEqual(['site/report.html']) // A turn that produced nothing yields no vocabulary at all. - expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined() + expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined() await fiber.dispose() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) diff --git a/packages/client/ui-slots/tests/surface.spec.ts b/packages/client/ui-slots/tests/surface.spec.ts index 12a5eeba15..e85aa324e1 100644 --- a/packages/client/ui-slots/tests/surface.spec.ts +++ b/packages/client/ui-slots/tests/surface.spec.ts @@ -7,6 +7,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { 'surface.a': { kind: 'single'; scope: 'root' } 'surface.b': { kind: 'single'; scope: 'root' } + 'surface.injected': { kind: 'single'; scope: 'root'; inject: { token: string } } } } @@ -28,6 +29,16 @@ describe('dynamic-key escape hatch', () => { expect(core.spec('surface.b')).toBeUndefined() }) + it('records the parent-declared Slot inject on the runtime spec', () => { + const core = new SlotCore() + const inject = { token: 'shared' } + core.register({ + name: 'root', + children: { 'surface.injected': { kind: 'single', scope: 'root', inject } }, + }, Comp as never) + expect(core.spec('surface.injected')?.inject).toBe(inject) + }) + it('entries/getVersion on an untouched key return the frozen empty array and 0', () => { const core = new SlotCore() expect(core.entries('surface.b')).toHaveLength(0) diff --git a/packages/client/ui-slots/tests/type-chain.spec.tsx b/packages/client/ui-slots/tests/type-chain.spec.tsx index b3a6aec873..64277edccc 100644 --- a/packages/client/ui-slots/tests/type-chain.spec.tsx +++ b/packages/client/ui-slots/tests/type-chain.spec.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' import type { - BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent, + BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent, SlotHookFactory, } from '@deepseek-ai/dsh-client-ui-slots' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' @@ -19,6 +19,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'chain.frame': { kind: 'single'; scope: 'root' } 'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } } 'chain.conv': { kind: 'single'; scope: 'session' } + 'chain.context': { + kind: 'single' + scope: 'session' + hookContext: string + inject: ContextInjected + } 'chain.tools': { kind: 'keyed'; scope: 'session' } 'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } } } @@ -62,6 +68,23 @@ type ConvProps = & PropsStore & { send: (t: string) => void } +interface TurnDataMap { tail: string; files: string } +type UseTurnData = (key: Key) => TurnDataMap[Key] | undefined +interface ContextInjected { + hooks: { + turnData: SlotHookFactory<'chain.context', UseTurnData> + } +} +type ContextProps = PropsRuntime<'chain.context'> +const CONTEXT_INJECT: ContextInjected = { + hooks: { + turnData: (_standard, hookContext) => { + const id: string = hookContext + return key => id === '' ? undefined : ({ tail: 'tail', files: 'files' })[key] + }, + }, +} + // Component fixtures (never rendered; the register call sites are the test). declare function Frame(props: FrameProps): ReactNode declare function Conv(props: ConvProps): ReactNode @@ -72,6 +95,8 @@ declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'c declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore>): ReactNode declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode +declare function ContextOwner(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.context'>): ReactNode +declare function ContextReader(props: ContextProps): ReactNode declare function Takeover(props: PropsRuntime<'chain.takeover'> & { matched: Item }): ReactNode declare function WideTakeover(props: PropsRuntime<'chain.takeover'> & { matched: Item | string }): ReactNode declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matched: { kind: 'q'; id: string; extra: number } }): ReactNode @@ -144,6 +169,24 @@ describe('terminal-design type chain', () => { chainSlots.renderSlotChain('chain.takeover', { items: [] }, { fallback: null }) chainSlots.renderSlot('chain.conv', {}) + // A parent registration declares the Slot inject once; every child + // entry receives the same custom Hook, bound to official standard props + // and each render occurrence's opaque context. + core.register({ + name: 'chain.frame', + children: { + 'chain.context': { kind: 'single', scope: 'session', inject: CONTEXT_INJECT }, + }, + }, ContextOwner) + core.register({ name: 'chain.context' }, ContextReader) + const contextProps: ContextProps = null as never + const tail: string | undefined = contextProps.useTurnData('tail') + const contextSlots: PropsRenderSlots<'chain.context'> = null as never + contextSlots.renderSlot('chain.context', {}, { + hookContext: 'turn:1', + }) + void tail + // ── negatives ────────────────────────────────────────────────── // children spec must match the SlotMap entry. core.register({ @@ -151,6 +194,11 @@ describe('terminal-design type chain', () => { // @ts-expect-error chain.conv is session-scoped in SlotMap children: { 'chain.conv': { kind: 'single', scope: 'root' } }, }, (() => null) as SlotComponent) + core.register({ + name: 'chain.frame', + // @ts-expect-error chain.context requires its Slot-level inject declaration + children: { 'chain.context': { kind: 'single', scope: 'session' } }, + }, ContextOwner) // renderSlot key set ⊄ children declaration. // @ts-expect-error component renderSlot keys exceed the declaration @@ -219,6 +267,16 @@ describe('terminal-design type chain', () => { // @ts-expect-error key not in this render share fp.renderSlot('chain.tools', {}) + // Contextual hooks preserve both the business key and value type. + // @ts-expect-error unknown Turn-data key + contextProps.useTurnData('other') + // @ts-expect-error a contextual slot requires its occurrence context + contextSlots.renderSlot('chain.context', {}) + // @ts-expect-error hookContext is the slot-declared string + const _wrongContextFactory: SlotHookFactory<'chain.context', UseTurnData> = + (_standard, _hookContext: number) => () => undefined + void _wrongContextFactory + // baked actions strip the draft parameter. acts.setDraft('x') // @ts-expect-error wrong payload type diff --git a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx index 9cbdfe3f1d..8f4fb384b0 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx @@ -8,6 +8,7 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' +import { toolChatSnapshot } from './tool-details-render.tsx' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -74,7 +75,7 @@ async function bench(nodes: ToolResultNode[]) { await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, - snapshot: { nodes }, + snapshot: { nodes, chat: toolChatSnapshot(nodes) }, session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 6c0bb956bd..3b70851a02 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -11,7 +11,9 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, WorkspaceListState, @@ -21,6 +23,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' +import { toolChatSnapshot } from './tool-details-render.tsx' const SID = 's1' as SessionId @@ -75,7 +78,8 @@ function snapshotWith( const nestedNodes = nodes.map(node => ({ ...node, subCalls })) const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls })) return { - sessionId: SID, nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, + sessionId: SID, chat: toolChatSnapshot(nestedNodes, nestedRunningCalls), + nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: nestedRunningCalls, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, @@ -97,6 +101,8 @@ async function bench(snapshot: ConversationSnapshot) { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) await slotsFiber.await() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() const slots = ctx.get('slots') as SlotsService const session = createSnapshotStore(snapshot) diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 7713bedaec..0cea47b572 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) @@ -342,8 +342,11 @@ describe('DetailsPanel diff Output section', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 739a787a03..f74bbae6be 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -24,7 +24,7 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/t import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' afterEach(cleanup) @@ -288,8 +288,11 @@ describe('DetailsPanel Output section (read)', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 51cab2d440..0016318b90 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' /** SearchRow now composes ToolRow, so its props include the locale `t` seat. */ type SearchRowProps = Parameters[0] @@ -404,8 +404,11 @@ describe('DetailsPanel Output section (search)', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index c9e2875a34..0dcb59d818 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' type BashRowProps = Parameters[0] @@ -479,8 +479,11 @@ describe('DetailsPanel Output section', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx index 393b9f8f46..44f4231802 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx @@ -29,12 +29,21 @@ function props( return { useSession, renderSlot, - callId: block.callId, - toolName: block.call?.name ?? '', - block, + node: { + key: `tool:${block.callId}`, + kind: 'tool-call', + id: block.callId, + target: 'chat', + anchorSeq: block.seq, + location: { kind: 'session' }, + visibility: 'visible', + data: { root: block }, + }, selectedCallId, openFile: vi.fn(), inspectCall: vi.fn(), + forkAt: vi.fn(), + fileMentions: vi.fn(), t, } as unknown as ToolTreeProps } diff --git a/packages/client/ui-tool/tests/tool-details-render.tsx b/packages/client/ui-tool/tests/tool-details-render.tsx index 0aeb3c5321..b2b2cd9ac7 100644 --- a/packages/client/ui-tool/tests/tool-details-render.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.tsx @@ -1,5 +1,7 @@ /** Test adapter for the production conversation.details.tool registration. */ -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId, +} from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' @@ -7,6 +9,45 @@ import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' /** Framework session-area seat used by direct DetailsPanel tests. */ export const SessionProviderStub: SessionProviderComponent = ({ children }) => children('s1' as SessionId) +/** Build the canonical Chat slice consumed by Tool rows and details tests. */ +export function toolChatSnapshot( + settled: readonly ConversationNode[] = [], + running: readonly RunningToolCall[] = [], +): ChatSnapshot { + const roots = [...settled.filter(node => node.kind === 'tool-result'), ...running] + const nodes: ChatConversationViewNode[] = roots.map(root => ({ + key: `tool:${root.callId}`, + kind: 'tool-call', + id: root.callId, + target: 'chat', + anchorSeq: 'kind' in root ? root.seq : Number.MAX_SAFE_INTEGER, + location: { kind: 'session' }, + visibility: 'visible', + data: { root }, + })) + const byKey = new Map(nodes.map(node => [node.key, node])) + const empty: readonly string[] = [] + return { + order: nodes.map(node => node.key), + nodes: { + get: key => byKey.get(key), + values: () => nodes, + }, + locations: { + getTurn: () => empty, + getStep: () => empty, + }, + timeline: { turnOrder: [], turns: new Map() }, + legacy: { + nodes: settled, + runningCalls: running, + partial: null, + turnTimings: new Map(), + turnEnds: new Map(), + }, + } +} + /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. @@ -16,7 +57,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; // recover the concrete owner selected by the adapter's fixed slot. - const details = owner as DetailsToolOwnerProps + const details = owner as unknown as DetailsToolOwnerProps return } } diff --git a/packages/client/ui-tool/tests/toolview-slot.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.spec.tsx index c927cc30ea..f1a9df3392 100644 --- a/packages/client/ui-tool/tests/toolview-slot.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.spec.tsx @@ -18,6 +18,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { toolChatSnapshot } from './tool-details-render.tsx' const SID = 's1' as SessionId @@ -71,7 +72,7 @@ async function bench(nodes: ToolResultNode[]) { await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S' }, - snapshot: { nodes }, + snapshot: { nodes, chat: toolChatSnapshot(nodes) }, session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 7856ae9d6f..44f9e05090 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' @@ -234,8 +234,11 @@ describe('DetailsPanel web Output section', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 16334ff7c7..0688d409b9 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -90,6 +90,7 @@ function makeHost() { const provide = observable(absentInfo) let currentId: string | undefined const infos = new Map() + const sessionSources = new Map>>() const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) @@ -160,17 +161,24 @@ function makeHost() { bump(key) } }, - addSession: (id: string): SessionProvideInfo => { + addSession: (id: string, initial: unknown = { sid: id }): SessionProvideInfo => { // Bare source per bundle (identity-stable): the machinery binds useSession from it. + const session = observable(initial) const info: SessionProvideInfo = { sessionId: id, - hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } }, + hooks: { session }, props: {}, } + sessionSources.set(id, session) infos.set(id, info) if (currentId === id) provide.set(info) return info }, + setSession: (id: string, snapshot: unknown) => { + const source = sessionSources.get(id) + if (source === undefined) throw new Error(`unknown test session: ${id}`) + source.set(snapshot) + }, } } @@ -635,6 +643,71 @@ describe('standard-kit synthesis', () => { expect(props['sessionId']).toBe('s1') }) + it('binds only function-valued inject hooks to the standard kit and render occurrence context', () => { + const h = makeHost() + const turnDataFactory = vi.fn((standard: AnyProps, turn: unknown) => (key: string) => { + const useSession = standard['useSession'] as (selector: (snapshot: unknown) => unknown) => unknown + return useSession((snapshot) => { + const value = snapshot as { turns: Record> } + return value.turns[turn as number]?.[key] + }) + }) + const sessionSpec: DeclaredSpec = { + kind: 'single', scope: 'session', inject: { hooks: { turnData: turnDataFactory } }, + } + h.declare('k.session', sessionSpec) + h.addSession('s1', { + turns: { 1: { tail: 'one' }, 2: { tail: 'two' } }, + unrelated: 0, + }) + const hooks = new Map unknown>>() + let renders = 0 + h.add('k.session', { + component: ({ label, useTurnData }: { label: string; useTurnData: (key: string) => unknown }) => { + renders += 1 + const seen = hooks.get(label) ?? [] + seen.push(useTurnData) + hooks.set(label, seen) + return {String(useTurnData('tail'))} + }, + }) + const { view } = mountRoot(h, { 'k.session': sessionSpec }, renderSlot => ( + {() => <> + {renderSlot('k.session', { label: 'one' }, { hookContext: 1 })} + {renderSlot('k.session', { label: 'two' }, { hookContext: 2 })} + } + + )) + act(() => { h.current.set('s1') }) + expect(view.container.textContent).toBe('onetwo') + expect(renders).toBe(2) + + // A session publication whose selected contextual value is identical is + // filtered by the framework-bound selector. + act(() => { + h.setSession('s1', { + turns: { 1: { tail: 'one' }, 2: { tail: 'two' } }, + unrelated: 1, + }) + }) + expect(renders).toBe(2) + + // Updating the selected contextual value re-renders through the returned + // custom Hook; the factory itself and its Hook identity stay stable. + act(() => { + h.setSession('s1', { + turns: { 1: { tail: 'updated' }, 2: { tail: 'two' } }, + unrelated: 1, + }) + }) + expect(view.container.textContent).toBe('updatedtwo') + expect(renders).toBe(3) + expect(hooks.get('one')![1]).toBe(hooks.get('one')![0]) + expect(hooks.get('two')).toHaveLength(1) + expect(hooks.get('one')![0]).not.toBe(hooks.get('two')![0]) + expect(turnDataFactory).toHaveBeenCalledTimes(2) + }) + it('hands the SessionProvider seat to entries declaring a session-scope child', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index 6922778a26..0c419d6799 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -4,6 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands' import { + CompactionId, CompactService, ManualCompactionError, type CompactAgentContext, @@ -14,7 +15,10 @@ import { import { Session, SessionId } from '@deepseek-ai/dsh-session' import * as commandCompact from '@deepseek-ai/dsh-command-compact' +const COMPACTION_ID = CompactionId('command-compact-test') + const RESULT: CompactionResult = { + compactionId: COMPACTION_ID, startSeq: 1, summarySeq: 2, endSeq: 3, @@ -45,18 +49,28 @@ class StubCompactService extends CompactService { override compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, + sourceCommandId?: Parameters[2], ): Promise { this.calls.push({ agent, signal }) if (this.operation !== undefined) return this.operation() return this.failure === undefined - ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result)) + ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result, sourceCommandId)) // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values. : Promise.reject(this.failure) } - private appendResult(agent: ManualCompactAgentContext, result: CompactionResult): CompactionResult { - agent.session.append('compact/start', { turn: null }) + private appendResult( + agent: ManualCompactAgentContext, + result: CompactionResult, + sourceCommandId: Parameters[2], + ): CompactionResult { + const provenance = { + compactionId: result.compactionId, + ...sourceCommandId === undefined ? {} : { sourceCommandId }, + } + agent.session.append('compact/start', { ...provenance, turn: null }) agent.session.append('compact/summary', { + ...provenance, summary: result.summary, shadowedRange: result.shadowedRange, shadowedSeqs: result.shadowedSeqs, @@ -64,8 +78,8 @@ class StubCompactService extends CompactService { provider: 'command-test', model: 'command-test', }) - agent.session.append('compact/end', { turn: null }) - return result + agent.session.append('compact/end', { ...provenance, turn: null }) + return { ...result, ...provenance } } } diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index 5a5d37d8b1..5f7fd0b346 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import { + CompactionId, CompactService, type CompactAgentContext, type CompactionResult, @@ -18,7 +19,10 @@ import { import * as commandCompact from '@deepseek-ai/dsh-command-compact' import { Session, SessionId } from '@deepseek-ai/dsh-session' +const COMPACTION_ID = CompactionId('loader-command-compact-test') + const RESULT: CompactionResult = { + compactionId: COMPACTION_ID, startSeq: 1, summarySeq: 2, endSeq: 3, @@ -44,9 +48,15 @@ class LoaderCompactService extends CompactService { override compactNow( agent: ManualCompactAgentContext, _signal: AbortSignal, + sourceCommandId?: Parameters[2], ): Promise { - agent.session.append('compact/start', { turn: null }) + const provenance = { + compactionId: RESULT.compactionId, + ...sourceCommandId === undefined ? {} : { sourceCommandId }, + } + agent.session.append('compact/start', { ...provenance, turn: null }) agent.session.append('compact/summary', { + ...provenance, summary: RESULT.summary, shadowedRange: RESULT.shadowedRange, shadowedSeqs: RESULT.shadowedSeqs, @@ -54,8 +64,8 @@ class LoaderCompactService extends CompactService { provider: 'loader-test', model: 'loader-test', }) - agent.session.append('compact/end', { turn: null }) - return Promise.resolve(RESULT) + agent.session.append('compact/end', { ...provenance, turn: null }) + return Promise.resolve({ ...RESULT, ...provenance }) } } @@ -132,11 +142,17 @@ describe('command-compact real Loader composition', () => { }, { type: 'compact/start', - data: { turn: null }, + data: { + compactionId: COMPACTION_ID, + sourceCommandId: execution.commandId, + turn: null, + }, }, { type: 'compact/summary', data: { + compactionId: COMPACTION_ID, + sourceCommandId: execution.commandId, summary: RESULT.summary, shadowedRange: RESULT.shadowedRange, shadowedSeqs: RESULT.shadowedSeqs, @@ -147,7 +163,11 @@ describe('command-compact real Loader composition', () => { }, { type: 'compact/end', - data: { turn: null }, + data: { + compactionId: COMPACTION_ID, + sourceCommandId: execution.commandId, + turn: null, + }, }, { type: 'command/done', diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 996d5d61e1..f97aaf52b9 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -4,7 +4,7 @@ import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts' -import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' +import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveCompactSpec, resolveConfig, @@ -945,7 +945,10 @@ describe('compaction region transaction', () => { )).rejects.toThrow(/no open turn/) const locked = conversation(1) - locked.append('compact/start', { turn: 2 }) + locked.append('compact/start', { + compactionId: CompactionId('locked-compaction'), + turn: 2, + }) const lockedNodes = locked.surface.nodes await expect(compact.compactRegion( lockedNodes[0]!, @@ -1623,6 +1626,7 @@ describe('automatic listener and loader composition', () => { const compact = new TestCompactService(ctx) const session = conversation(2) const fakeResult: CompactionResult = { + compactionId: CompactionId('fake-compaction'), startSeq: 1, summarySeq: 2, endSeq: 3, diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index 7544c3223b..0599d3f364 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -3,13 +3,14 @@ import { Context } from 'cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantService from '@deepseek-ai/dsh-invariants' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' 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 * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' import * as CompactBasicInvariant from '@deepseek-ai/dsh-compact-basic/invariant' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' -import { isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact' +import { CompactionId, isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { createAssistantMessage, @@ -23,7 +24,7 @@ import type { StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import LlmService from '@deepseek-ai/dsh-llm' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -394,22 +395,35 @@ describe('compactNow transaction and failure classification', () => { const { compact, flushes } = detachedService() const session = closedConversation(2, 7) const agent = fakeAgent(session, () => () => undefined) + const commandId = CommandId('manual-compact-command') - const result = await compact.compactNow(agent, SIGNAL) + const result = await compact.compactNow(agent, SIGNAL, commandId) expect(result).not.toBeNull() + expect(result?.sourceCommandId).toBe(commandId) expect(flushes()).toBe(1) expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) - expect(session.events.findLast(event => event.type === 'compact/start')?.data) - .toEqual({ turn: null }) - expect(session.events.findLast(event => event.type === 'compact/end')?.data) - .toEqual({ turn: null }) + const start = session.events.findLast(event => event.type === 'compact/start') + const summaryEvent = session.events.findLast(event => event.type === 'compact/summary') + const checkpoint = session.events.findLast( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' + && isCompactCheckpointSource(event.data.source), + ) + const end = session.events.findLast(event => event.type === 'compact/end') + const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId } + expect(start?.data).toEqual({ ...correlated, turn: null }) + expect(summaryEvent?.data.sourceCommandId).toBe(commandId) + expect(checkpoint?.data.source).toMatchObject(correlated) + expect(end?.data).toEqual({ ...correlated, turn: null }) }) it('reports a live unmatched bracket as busy without summarizing', async () => { const { compact } = detachedService() const session = closedConversation(2) - session.append('compact/start', { turn: null }) + session.append('compact/start', { + compactionId: CompactionId('live-manual-compaction'), + turn: null, + }) const agent = fakeAgent(session, () => () => undefined) const error = await rejection(() => compact.compactNow(agent, SIGNAL)) @@ -421,7 +435,10 @@ describe('compactNow transaction and failure classification', () => { it('ignores an unmatched bracket inherited before a later end-seed marker', async () => { const { compact } = detachedService() const original = closedConversation(2) - original.append('compact/start', { turn: null }) + original.append('compact/start', { + compactionId: CompactionId('stale-manual-compaction'), + turn: null, + }) const reloaded = Session.create(SessionId('stale-orphan'), [...original.events]) const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed') const orphan = reloaded.events.find(event => event.type === 'compact/start') @@ -435,7 +452,10 @@ describe('compactNow transaction and failure classification', () => { it('scans a stale orphan independently of later repaired turn state', async () => { const { compact } = detachedService() const original = closedConversation(2) - original.append('compact/start', { turn: null }) + original.append('compact/start', { + compactionId: CompactionId('reloaded-manual-compaction'), + turn: null, + }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events]) @@ -664,7 +684,7 @@ describe('compactNow transaction and failure classification', () => { expect(result).not.toBeNull() expect(session.events.some(event => event.type === 'turn/start')).toBe(false) expect(session.events.find(event => event.type === 'compact/start')?.data) - .toEqual({ turn: null }) + .toEqual({ compactionId: result?.compactionId, turn: null }) }) it('classifies a durability failure after the standalone bracket committed', async () => { @@ -676,8 +696,9 @@ describe('compactNow transaction and failure classification', () => { expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence') vi.restoreAllMocks() expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) - expect(session.events.findLast(event => event.type === 'compact/end')?.data) - .toEqual({ turn: null }) + const start = session.events.findLast(event => event.type === 'compact/start') + const end = session.events.findLast(event => event.type === 'compact/end') + expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null }) }) it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => { diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 7e6915354f..74e2bd0270 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -2,8 +2,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { - COMPACT_CHECKPOINT_SOURCE, + CompactionId, CompactService, + compactCheckpointSource, isCompactCheckpointSource, } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' @@ -52,9 +53,11 @@ class StubCompactService extends CompactService { const endIndex = surface.indexOf(end) if (startIndex < 0 || endIndex < startIndex) throw new Error('stub compact range is invalid') const shadowedSeqs = surface.slice(startIndex, endIndex + 1) + const compactionId = CompactionId('stub-compaction') // Minimal stub honoring the lock + log-only event contract. - const startEvent = session.append('compact/start', { turn: 0 }) + const startEvent = session.append('compact/start', { compactionId, turn: 0 }) const summaryEvent = session.append('compact/summary', { + compactionId, summary, shadowedRange: { start, end }, shadowedSeqs, @@ -64,13 +67,14 @@ class StubCompactService extends CompactService { }) session.append('user/message', createUserMessage({ content: summary, - source: COMPACT_CHECKPOINT_SOURCE, + source: compactCheckpointSource(compactionId), }), { surfaceOp: { op: 'replace', start, end }, sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], }) - const endEvent = session.append('compact/end', { turn: 0 }) + const endEvent = session.append('compact/end', { compactionId, turn: 0 }) return { + compactionId, startSeq: startEvent.seq, summarySeq: summaryEvent.seq, endSeq: endEvent.seq, @@ -139,7 +143,8 @@ describe('CompactService seam', () => { expect(result.shadowedSeqs).toEqual([original.seq]) const checkpoint = session.events.find(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source)) - expect(checkpoint?.type === 'user/message' && checkpoint.data.source).toEqual(COMPACT_CHECKPOINT_SOURCE) + expect(checkpoint?.type === 'user/message' && checkpoint.data.source) + .toEqual(compactCheckpointSource(result.compactionId)) expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false) expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false) expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type)) diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index c5a68c9de6..1637c16179 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' import InvariantService from '@deepseek-ai/dsh-invariants' async function setup(): Promise { @@ -12,7 +15,13 @@ async function setup(): Promise { return ctx } +const TEST_COMPACTION_ID = CompactionId('test-compaction') +const NEXT_COMPACTION_ID = CompactionId('next-test-compaction') +const TEST_COMMAND_ID = CommandId('test-command') +const NEXT_COMMAND_ID = CommandId('next-test-command') + const summary = (overrides: Record = {}) => ({ + compactionId: TEST_COMPACTION_ID, summary: [{ type: 'text' as const, text: 'short' }], shadowedRange: { start: 2, end: 4 }, shadowedSeqs: [2, 3, 4], @@ -31,33 +40,33 @@ describe('compaction invariants', () => { const ctx = await setup() const success = ctx.sessions.create() startTurn(success) - success.append('compact/start', { turn: 1 }) + success.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) success.append('compact/summary', summary()) - success.append('compact/end', { turn: 1 }) + success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const failed = ctx.sessions.create() startTurn(failed, 2) - failed.append('compact/start', { turn: 2 }) - failed.append('compact/end', { turn: 2, error: 'provider failed' }) + failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 2 }) + failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'provider failed' }) }) it('accepts standalone successful and failed compaction lifecycles between turns', async () => { const ctx = await setup() const success = ctx.sessions.create() - success.append('compact/start', { turn: null }) + success.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) success.append('compact/summary', summary()) - success.append('compact/end', { turn: null }) + success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null }) const failed = ctx.sessions.create() - failed.append('compact/start', { turn: null }) - failed.append('compact/end', { turn: null, error: 'provider failed' }) + failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) + failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'provider failed' }) }) it('clears an inherited open compaction trace at end-seed during replay', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const source = Session.create(SessionId('stale-compaction-source')) - source.append('compact/start', { turn: null }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), { seed: source.events, }) @@ -68,8 +77,8 @@ describe('compaction invariants', () => { await ctx.plugin(CompactInvariant) expect(() => { - replayed.append('compact/start', { turn: null }) - replayed.append('compact/end', { turn: null, error: 'new attempt failed' }) + replayed.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null }) + replayed.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: null, error: 'new attempt failed' }) }).not.toThrow() }) @@ -78,7 +87,7 @@ describe('compaction invariants', () => { await ctx.plugin(SessionStore) const source = Session.create(SessionId('stale-numbered-compaction-source')) startTurn(source) - source.append('compact/start', { turn: 1 }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), { seed: source.events, }) @@ -98,7 +107,7 @@ describe('compaction invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const source = Session.create(SessionId('stale-repaired-compaction-source')) - source.append('compact/start', { turn: null }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), { @@ -124,10 +133,10 @@ describe('compaction invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const source = Session.create(SessionId('closed-nested-compaction-source')) - source.append('compact/start', { turn: null }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) - source.append('compact/end', { turn: null, error: 'failed after crossing turn' }) + source.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'failed after crossing turn' }) const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), { seed: source.events, }) @@ -143,10 +152,14 @@ describe('compaction invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) await ctx.plugin(InvariantService) await ctx.plugin(CompactInvariant) - expect(() => session.append('compact/end', { turn: 1, error: 'resume failed' })).not.toThrow() + expect(() => session.append('compact/end', { + compactionId: TEST_COMPACTION_ID, + turn: 1, + error: 'resume failed', + })).not.toThrow() session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) @@ -162,7 +175,8 @@ describe('compaction invariants', () => { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 }, }) ctx.emit('session/event', session, { - type: 'compact/start', seq: 2, time: 2, data: { turn: 1 }, + type: 'compact/start', seq: 2, time: 2, + data: { compactionId: TEST_COMPACTION_ID, turn: 1 }, }) }).not.toThrow() }) @@ -170,28 +184,30 @@ describe('compaction invariants', () => { it('rejects compaction outside or for a different open turn', async () => { const ctx = await setup() const session = ctx.sessions.create() - expect(() => session.append('compact/start', { turn: 1 })).toThrow(/outside any open turn/) + expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })) + .toThrow(/outside any open turn/) startTurn(session) - expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/) + expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 2 })) + .toThrow(/but open turn is 1/) }) it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => { const ctx = await setup() const open = ctx.sessions.create() startTurn(open) - expect(() => open.append('compact/start', { turn: null })) + expect(() => open.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })) .toThrow(/standalone but turn 1 is open/) const idle = ctx.sessions.create() - expect(() => idle.append('compact/start', { turn: 1 })) + expect(() => idle.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })) .toThrow(/outside any open turn/) }) it('attributes a nested standalone start to the standalone owner', async () => { const ctx = await setup() const session = ctx.sessions.create() - session.append('compact/start', { turn: null }) - expect(() => session.append('compact/start', { turn: null })) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) + expect(() => session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null })) .toThrow(/standalone compaction is still compacting/) }) @@ -201,7 +217,7 @@ describe('compaction invariants', () => { const session = ctx.sessions.create() startTurn(session) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) await ctx.plugin(InvariantService) await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) }) @@ -209,10 +225,10 @@ describe('compaction invariants', () => { it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => { const ctx = await setup() const standalone = ctx.sessions.create() - standalone.append('compact/start', { turn: null }) + standalone.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) expect(() => { startTurn(standalone) }) .toThrow(/turn\/start cannot cross an open standalone compaction/) - standalone.append('compact/end', { turn: null, error: 'cancelled' }) + standalone.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'cancelled' }) expect(() => { startTurn(standalone) standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -220,53 +236,151 @@ describe('compaction invariants', () => { const numbered = ctx.sessions.create() startTurn(numbered) - numbered.append('compact/start', { turn: 1 }) + numbered.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) expect(() => numbered.append( 'turn/end', { turn: 1, reason: { kind: 'completed' } }, )).toThrow(/turn\/end cannot cross an open compaction for turn 1/) - numbered.append('compact/end', { turn: 1, error: 'cancelled' }) + numbered.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'cancelled' }) expect(() => numbered.append( 'turn/end', { turn: 1, reason: { kind: 'completed' } }, )).not.toThrow() }) + it('rejects a replacement checkpoint for another compaction transaction', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const original = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + startTurn(session) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/summary', summary()) + + expect(() => session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(NEXT_COMPACTION_ID), + }), { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).toThrow(/compaction checkpoint id .* does not match compact\/start id/) + }) + + it('requires checkpoint provenance to name an open transaction', async () => { + const ctx = await setup() + const withoutStart = ctx.sessions.create() + const original = withoutStart.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + expect(() => withoutStart.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(TEST_COMPACTION_ID), + }), { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).toThrow(/no matching compact\/start/) + + const emptyCommand = ctx.sessions.create() + const replaced = emptyCommand.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + startTurn(emptyCommand) + emptyCommand.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + expect(() => emptyCommand.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(TEST_COMPACTION_ID, CommandId('')), + }), { + surfaceOp: { op: 'replace', start: replaced.seq, end: replaced.seq }, + sourceEventSeqs: [replaced.seq], + })).toThrow(/checkpoint sourceCommandId must be a non-empty string/) + }) + it.each([ + ['empty start id', (session: ReturnType) => { + session.append('compact/start', { compactionId: CompactionId(''), turn: 1 }) + }, /compact\/start compactionId must be a non-empty string/], + ['empty start source command id', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: CommandId(''), + turn: 1, + }) + }, /compact\/start sourceCommandId must be a non-empty string/], ['summary without start', (session: ReturnType) => { session.append('compact/summary', summary()) }, /no matching compact\/start/], ['nested start', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) - session.append('compact/start', { turn: 2 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: 2 }) }, /still compacting/], ['repeated summary', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary()) session.append('compact/summary', summary()) }, /repeated within one compaction/], + ['summary for another compaction', (session: ReturnType) => { + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/summary', summary({ compactionId: NEXT_COMPACTION_ID })) + }, /compact\/summary id .* does not match compact\/start id/], + ['summary for another source command', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: TEST_COMMAND_ID, + turn: 1, + }) + session.append('compact/summary', summary({ sourceCommandId: NEXT_COMMAND_ID })) + }, /compact\/summary sourceCommandId .* does not match compact\/start sourceCommandId/], ['empty shadow set', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary({ shadowedSeqs: [] })) }, /shadowedSeqs must be non-empty/], ['wrong endpoints', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary({ shadowedRange: { start: 1, end: 4 } })) }, /shadowedRange must match/], ['invalid token count', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary({ shadowedTokenCount: -1 })) }, /non-negative safe integer/], ['end without start', (session: ReturnType) => { - session.append('compact/end', { turn: 1, error: 'failed' }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' }) }, /no matching compact\/start/], ['wrong end turn', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) - session.append('compact/end', { turn: 2, error: 'failed' }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'failed' }) }, /does not match/], + ['end for another compaction', (session: ReturnType) => { + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: 1, error: 'failed' }) + }, /compact\/end id .* does not match compact\/start id/], + ['end missing the source command', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: TEST_COMMAND_ID, + turn: 1, + }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' }) + }, /compact\/end sourceCommandId .* does not match compact\/start sourceCommandId/], + ['empty end source command id', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: TEST_COMMAND_ID, + turn: 1, + }) + session.append('compact/end', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: CommandId(''), + turn: 1, + error: 'failed', + }) + }, /compact\/end sourceCommandId must be a non-empty string/], ['success without summary', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) - session.append('compact/end', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 }) }, /requires one compact\/summary/], ])('rejects %s', async (_name, action, message) => { const ctx = await setup() diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index fcdcd7c422..585397d861 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -196,11 +196,15 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { } function stubToolExecution( - input: Omit & { token?: ToolExecutionToken }, + input: Omit & { + token?: ToolExecutionToken + rootCallId?: ToolExecution['rootCallId'] + }, ): ToolExecution { return { token: input.token ?? Symbol('workspace-context-test-execution') as ToolExecutionToken, ...input, + rootCallId: input.rootCallId ?? input.callId, } } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 06993467e8..2f3454b03c 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -785,11 +785,11 @@ describe('the run_code dispatch bridge', () => { const dispatches = events.filter(event => event.type === 'tool/code-dispatch') expect(dispatches.map(event => event.data)).toEqual([ { - parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', + rootCallId: 'call-1', parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, content: [{ type: 'text', text: 'echo:one' }], }, { - parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', + rootCallId: 'call-1', parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, content: [{ type: 'text', text: 'echo:two' }], }, ]) @@ -1526,6 +1526,7 @@ describe('the run_code dispatch bridge', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('tool/code-dispatch', { + rootCallId: CallId('p1'), parentCallId: CallId('p1'), subCallId: CallId('p1:code:1'), name: 'echo', diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 80ae299da7..8a60c41331 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -24,6 +24,7 @@ const execution = (overrides: Partial = {}): ToolExecution => ({ arguments: Object.freeze({ text: 'hi' }), ...overrides, signal: overrides.signal ?? testToolSignal, + rootCallId: overrides.rootCallId ?? overrides.callId ?? CallId('call-1'), }) const outcome = (): ToolExecutionResult => Object.freeze({ @@ -92,6 +93,7 @@ describe('tool-pipeline invariants', () => { const ctx = await setup() const session = ctx.sessions.create() const data = { + rootCallId: CallId('parent'), parentCallId: CallId('parent'), subCallId: CallId('child'), name: 'echo', @@ -103,12 +105,112 @@ describe('tool-pipeline invariants', () => { session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) + it('does not commit a rejected dispatch edge into the root index', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('rejected-root'), + parentCallId: CallId('rejected-root'), + subCallId: CallId('reused-child'), + name: 'echo', + arguments: {}, + })).toThrow(/outside any open turn/) + + session.append('turn/start', { turn: 1 }) + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('accepted-root'), + parentCallId: CallId('accepted-root'), + subCallId: CallId('reused-child'), + name: 'echo', + arguments: {}, + })).not.toThrow() + }) + + it('rejects a nested code dispatch that changes its parent chain root before append', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1 }) + session.append('tool/code-dispatch-start', { + rootCallId: CallId('root'), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'run_code', + arguments: {}, + }) + session.append('tool/code-dispatch-start', { + rootCallId: CallId('root'), + parentCallId: CallId('child'), + subCallId: CallId('grandchild'), + name: 'echo', + arguments: {}, + }) + + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('another-root'), + parentCallId: CallId('child'), + subCallId: CallId('invalid-grandchild'), + name: 'echo', + arguments: {}, + })).toThrow(/parentCallId child does not belong to rootCallId another-root/) + expect(session.events.some(event => event.type === 'tool/code-dispatch-start' + && String(event.data.subCallId) === 'invalid-grandchild')).toBe(false) + }) + + it('requires non-empty dispatch identities and keeps one subcall on one root', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1 }) + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId(''), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + })).toThrow(/must carry non-empty rootCallId/) + + session.append('tool/code-dispatch-start', { + rootCallId: CallId('root'), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + }) + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('other-root'), + parentCallId: CallId('other-root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + })).toThrow(/changed rootCallId for subCallId child/) + }) + + it('indexes dispatch records emitted for a bare session', async () => { + const ctx = await setup() + const session = Session.create(SessionId('bare-dispatch-session')) + session.append('turn/start', { turn: 1 }) + expect(() => { + ctx.emit('session/event', session as never, { + type: 'tool/code-dispatch-start', + seq: 1, + time: 1, + data: { + rootCallId: CallId('root'), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + }, + } as never) + }).not.toThrow() + }) + it('replays enclosed code-dispatch records on late registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('tool/code-dispatch', { + rootCallId: CallId('parent'), parentCallId: CallId('parent'), subCallId: CallId('child'), name: 'echo', @@ -125,6 +227,7 @@ describe('tool-pipeline invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) ctx.sessions.create().append('tool/code-dispatch-start', { + rootCallId: CallId('parent'), parentCallId: CallId('parent'), subCallId: CallId('child'), name: 'echo', diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 3224487b60..5757e5f245 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -574,6 +574,7 @@ describe('dsh-agent-spine-demo bundle', () => { signal: testToolSignal, token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'], callId: CallId('agent-core-dsh-home'), + rootCallId: CallId('agent-core-dsh-home'), name: 'bash', arguments: { command: 'true' }, } diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 1c478b226b..33ac3a19fa 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -5,6 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' import { providerForOpenStep } from '../src/history.ts' async function setup(): Promise { @@ -38,6 +39,7 @@ function appendRetryTurn(session: Session, turn: number) { const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } const normal = { + retryId: RetryId('normal-retry-chain'), provider: 'mock', mode: 'normal' as const, policyKey: 'normal-policy', @@ -47,6 +49,7 @@ const normal = { failure, } const always = { + retryId: RetryId('always-retry-chain'), provider: 'mock', mode: 'always' as const, policyKey: 'always-policy', @@ -130,6 +133,7 @@ describe('llm-retry invariants', () => { }) it.each([ + ['empty-retry-id', { ...normal, retryId: RetryId('') }, /retryId must be a non-empty string/], ['retry-zero', { ...normal, retry: 0 }, /positive safe integer/], ['retry-fraction', { ...normal, retry: 1.5 }, /positive safe integer/], ['max-zero', { ...normal, maxRetries: 0 }, /positive safe maxRetries/], @@ -212,10 +216,65 @@ describe('llm-retry invariants', () => { reset.append('step/end', { turn: 1, step: 1 }) reset.append('step/start', { turn: 1, step: 2 }) expect(() => { - reset.append('llm/retry', { turn: 1, step: 2, ...normal }) + reset.append('llm/retry', { + turn: 1, + step: 2, + ...normal, + retryId: RetryId('reset-step-2-retry-chain'), + }) }).not.toThrow() }) + it('keeps one retry identity per provider-policy chain', async () => { + const ctx = await setup() + const changed = openStep(ctx, 'retry-invariant-changed-chain-id') + changed.append('llm/retry', { turn: 1, step: 1, ...normal }) + expect(() => changed.append('llm/retry', { + turn: 1, + step: 1, + ...normal, + retry: 2, + retryId: RetryId('changed-retry-chain'), + })).toThrow(/must preserve retryId/) + + const reused = openStep(ctx, 'retry-invariant-reused-chain-id') + reused.append('llm/retry', { turn: 1, step: 1, ...normal }) + expect(() => reused.append('llm/retry', { + turn: 1, + step: 1, + ...always, + retryId: normal.retryId, + })).toThrow(/already owned by another chain/) + }) + + it('validates retry-started correlation and uniqueness', async () => { + const ctx = await setup() + const empty = openStep(ctx, 'retry-started-empty-id') + expect(() => empty.append('llm/retry-started', { + retryId: RetryId(''), turn: 1, step: 1, retry: 1, + })).toThrow(/retryId must be a non-empty string/) + + const missing = openStep(ctx, 'retry-started-missing-schedule') + expect(() => missing.append('llm/retry-started', { + retryId: RetryId('missing-retry-chain'), turn: 1, step: 1, retry: 1, + })).toThrow(/pairs no prior scheduled attempt/) + + const mismatch = openStep(ctx, 'retry-started-location-mismatch') + mismatch.append('llm/retry', { turn: 1, step: 1, ...normal }) + expect(() => mismatch.append('llm/retry-started', { + retryId: normal.retryId, turn: 2, step: 1, retry: 1, + })).toThrow(/turn\/step must match/) + + const repeated = openStep(ctx, 'retry-started-repeated') + repeated.append('llm/retry', { turn: 1, step: 1, ...normal }) + repeated.append('llm/retry-started', { + retryId: normal.retryId, turn: 1, step: 1, retry: 1, + }) + expect(() => repeated.append('llm/retry-started', { + retryId: normal.retryId, turn: 1, step: 1, retry: 1, + })).toThrow(/repeats one scheduled attempt/) + }) + it('starts a fresh retry chain after incomplete predecessor boundaries', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -261,4 +320,16 @@ describe('llm-retry invariants', () => { await ctx.plugin(InvariantService) await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) }) + + it('accepts a scheduled and started attempt on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = openStep(ctx, 'retry-invariant-late-started') + session.append('llm/retry', { turn: 1, step: 1, ...normal }) + session.append('llm/retry-started', { + retryId: normal.retryId, turn: 1, step: 1, retry: 1, + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined() + }) }) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 36a1fb5dbe..3a605bd40e 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -6,6 +6,7 @@ import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' import type {} from '../src/index.ts' const dirs: string[] = [] @@ -39,6 +40,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) reason: 'initial', }) const event = session.append('llm/retry', { + retryId: RetryId(`retry-${kind}-chain`), turn: 1, step: 1, provider: 'mock', diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index ac0ed687fa..7268c19ec6 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -191,7 +191,9 @@ describe('provider-routed retry policy', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) const event = await scheduled + expect(event.data.retryId).toEqual(expect.any(String)) expect(event.data).toEqual({ + retryId: event.data.retryId, turn: 1, step: 1, provider: 'mock', diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 20cb2cc819..9251eef2c0 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -10,6 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' +import { CompactionId } from '@deepseek-ai/dsh-compact/brand' import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts' import { estimateContent, @@ -59,6 +60,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end: const endIdx = nodes.findIndex(node => node.seq === end) const shadowed = nodes.slice(startIdx, endIdx + 1) session.append('compact/summary', { + compactionId: CompactionId('context-breakdown-summary'), summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start, end }, shadowedSeqs: shadowed.map(node => node.seq), diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 0307b96f46..65771fc877 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -7,6 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' +import { CompactionId } from '@deepseek-ai/dsh-compact/brand' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, @@ -81,6 +82,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end: const endIdx = nodes.findIndex(node => node.seq === end) const shadowed = nodes.slice(startIdx, endIdx + 1) session.append('compact/summary', { + compactionId: CompactionId('token-usage-summary'), summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start, end }, shadowedSeqs: shadowed.map(node => node.seq), diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index ef97c945c9..b52000f554 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -277,8 +277,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Consider automatic compaction for one explicit trigger. Pressure policy\n * uses the latest durable routed request, while context-overflow policy may\n * force a useful balanced reduction even below the normal threshold. Return\n * `null` when no safe range can be compacted. A single oversized retained\n * unit or request envelope cannot be repaired through surface compaction.\n *\n * @param agent - agent context owning the session surface and routing options.\n * @param trigger - normal pressure or provider-confirmed context overflow.\n * @param signal - cancellation signal; model-backed implementations must forward it.\n * @returns the compaction result, or `null` if no compaction was needed.\n */', }, { - signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise', - jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */', + signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise', + jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @param sourceCommandId - initiating command identity for a manual compaction.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */', }, { signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', @@ -1841,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', @@ -1851,9 +1851,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CompactAgentContext', declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', }, + { + name: 'CompactionId', + declaration: 'export type CompactionId = Branded<\'CompactionId\'>;', + }, { name: 'CompactionResult', - declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', + declaration: 'export interface CompactionResult {\n compactionId: CompactionId;\n sourceCommandId?: CommandId;\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', }, { name: 'CompactionTrigger', @@ -3065,7 +3069,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecution', - declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}', + declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n}', }, { name: 'ToolExecutionFailure', @@ -3073,7 +3077,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionInput', - declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}', + declaration: '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}', }, { name: 'ToolExecutionMode', diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index efdab32824..0276199e3e 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { CompactionId } from '@deepseek-ai/dsh-compact/brand' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, @@ -35,6 +36,8 @@ const TEXT_CHUNKS: StreamChunk[] = [ { type: 'finish', reason: { kind: 'stop' } }, ] +const COMPACTION_ID = CompactionId('replay-compaction') + /** Build a minimal session-JSONL string: a header line + the given events. */ function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number; seedLength?: number }): string { const headerLine = JSON.stringify({ @@ -194,12 +197,18 @@ describe('deriveReplayScript', () => { let seq = 1 const events: SessionEvent[] = [ ...overflow.map(chunk => chunkEvent(seq++, 1, 2, chunk)), - { type: 'compact/start', seq: seq++, time: 0, data: { turn: 1 } }, + { + type: 'compact/start', + seq: seq++, + time: 0, + data: { compactionId: COMPACTION_ID, turn: 1 }, + }, { type: 'compact/summary', seq: seq++, time: 0, data: { + compactionId: COMPACTION_ID, summary: rawOutput, rawOutput, llmStreamCall: true, @@ -227,6 +236,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'template result' }], shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], @@ -246,6 +256,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [block], rawOutput: [block], shadowedRange: { start: 1, end: 1 }, @@ -267,6 +278,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'missing source events' }], llmStreamCall: true, shadowedRange: { start: 1, end: 1 }, @@ -289,6 +301,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [block], rawOutput: [block], llmStreamCall: true, @@ -343,6 +356,7 @@ describe('deriveReplayScript', () => { seq: 2, time: 0, data: { + compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'external checkpoint' }], shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da3703e5ff..f1ee272761 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1504,9 +1504,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-compact': - specifier: workspace:^ - version: link:../../compact/compact '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -1680,6 +1677,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -2670,6 +2670,12 @@ importers: packages/compact/compact: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2704,6 +2710,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../compact @@ -4391,6 +4400,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fffde3ceb8..51fdc06f61 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -278,6 +278,7 @@ export const LINK_MAP: Readonly> = { CreateGoalResult: 'goal.md', CommandDefinition: 'commands.md', CommandDescriptor: 'commands.md', + CommandId: 'commands.md', CommandResult: 'commands.md', CommandSurface: 'commands.md', LlmAdapter: 'llm-streaming.md', From 8723a0e15f791f4403327f809eb43bedc748f43d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:02:20 +0800 Subject: [PATCH 09/32] fix(client): address conversation assembly review findings --- .../client/sessions/conversation-assembler.ts | 12 ++-- .../runtime/src/client/sessions/session.ts | 1 + .../tests/conversation-assembler.spec.ts | 67 +++++++++++++++++++ .../client/conversation-nodes/turn-tail.ts | 19 +++++- .../ui-conversation/src/client/index.ts | 21 +++--- .../conversation-node-definitions.spec.ts | 26 ++++++- scripts/gen-cordis-catalog.ts | 2 + 7 files changed, 128 insertions(+), 20 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index 3b6fbd6266..3a0c94ba3c 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -238,7 +238,7 @@ export class ConversationNodeAssembler { } this.applyPendingMatches(pending, affected) this.replayContexts(affected) - if ((fresh.length > 0 || previousHasMore !== hasMore) && this.replayDependencies()) { + if ((this.revised.size > 0 || previousHasMore !== hasMore) && this.replayDependencies()) { publication = 'immediate' } if (changedLocations.size > 0) publication = 'immediate' @@ -563,18 +563,18 @@ export class ConversationNodeAssembler { private replayRevisedDependents(): boolean { const pending = [...this.revised] - const replayed = new Set() + const affected = new Set() for (let index = 0; index < pending.length; index++) { const dependency = pending[index] if (dependency === undefined) continue for (const dependent of this.dependents.get(dependency.key) ?? []) { - if (replayed.has(dependent)) continue - replayed.add(dependent) - this.replayContext(dependent) + if (affected.has(dependent)) continue + affected.add(dependent) pending.push(dependent) } } - return replayed.size > 0 + this.replayContexts(affected) + return affected.size > 0 } private readerFor( diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index a533f9dd6f..7c3d989248 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -344,6 +344,7 @@ export class Session implements SessionFace { // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream. console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`) this.hasMore = false + this.conversation.prepend([], false) return } this.events = [...older.map(e => e.event), ...this.events] diff --git a/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.spec.ts index 6f4d8516dc..06dfd42567 100644 --- a/packages/client/runtime/tests/conversation-assembler.spec.ts +++ b/packages/client/runtime/tests/conversation-assembler.spec.ts @@ -458,6 +458,73 @@ describe('ConversationNodeAssembler', () => { expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2) }) + it('replays a transitive dependency closure in start order', () => { + const sourceA: ConversationNodeDefinition = { + kind: 'diamond-a', + match: (event) => { + if (event.type === 'user/message') return { id: 'one', role: 'start' } + if ((event.type as string) === 'diamond/a') return { id: 'one', role: 'update' } + return null + }, + start: () => 1, + update: (_context, match) => (match.event.data as unknown as { value: number }).value, + buildViewNode: () => null, + } + const sourceX: ConversationNodeDefinition = { + kind: 'diamond-x', + match: (event) => { + if (event.type === 'turn/start') return { id: 'one', role: 'start' } + if ((event.type as string) === 'diamond/x') return { id: 'one', role: 'update' } + return null + }, + start: () => 10, + update: (_context, match) => (match.event.data as unknown as { value: number }).value, + buildViewNode: () => null, + } + const middle: ConversationNodeDefinition = { + kind: 'diamond-b', + match: event => event.type === 'assistant/message' + ? { id: 'one', role: 'start' } + : null, + start: (_context, _match, reader) => ( + (reader.previous('diamond-a')?.state ?? 0) + + (reader.previous('diamond-x')?.state ?? 0) + ), + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const consumer: ConversationNodeDefinition = { + kind: 'diamond-c', + match: event => event.type === 'tool/call' + ? { id: 'one', role: 'start' } + : null, + start: (_context, _match, reader) => ( + (reader.previous('diamond-a')?.state ?? 0) * 100 + + (reader.previous('diamond-b')?.state ?? 0) + ), + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([sourceA, sourceX, middle, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })), + input(at(2, 'turn/start', { turn: 1 })), + input(at(3, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })), + input(at(4, 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })), + ], false) + + assembler.append(input(at(5, 'diamond/x', { value: 20 }))) + assembler.append(input(at(6, 'diamond/a', { value: 2 }))) + assembler.flush() + + const value = [...chatSnapshot(assembler)?.nodes.values() ?? []] + .find(candidate => candidate.kind === 'diamond-c') + expect(value?.data).toBe(222) + }) + it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => { const apply = vi.fn() const starts = vi.fn(( diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts index 2a842a005d..346bd41bdf 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -122,14 +122,26 @@ function tailData(context: ConversationNodeContext): TurnTailChat .filter((candidate): candidate is Readonly => candidate.finalNode !== undefined) .sort((left, right) => left.finalNode.seq - right.finalNode.seq) const closing = finalized.findLast(hasText) ?? null - const latest = finalized.at(-1) + let latestTranscriptSeq = finalized.at(-1)?.finalNode.seq + for (const match of context.matches) { + const event = match.event + const candidate = event.type === 'tool/call' + || (event.type === 'tool/result' && isAppendSurfaceEvent(event)) + || (event.type === 'turn/end' && event.data.reason.kind === 'error') + || (event.type as string) === 'llm/retry' + ? event.seq + : undefined + if (candidate !== undefined && (latestTranscriptSeq === undefined || candidate > latestTranscriptSeq)) { + latestTranscriptSeq = candidate + } + } const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn) return { turn: end.event.data.turn, seq: end.event.seq, time: end.event.time, closing, - branchUnavailable: closing === null || latest?.finalNode.seq !== closing.finalNode.seq, + branchUnavailable: closing === null || latestTranscriptSeq !== closing.finalNode.seq, ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs }, ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond }, } @@ -141,6 +153,9 @@ export const turnTailDefinition: ConversationNodeDefinition = { match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' } + if (event.type === 'tool/call' || event.type === 'tool/result') { + return { id: String(event.data.turn), role: 'update' } + } const coordinates = turnCoordinates(event) if (coordinates !== undefined) return { id: String(coordinates.turn), role: 'update' } return null diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index b4d7467170..e0471623f0 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -3,19 +3,18 @@ * between the independently implemented skeleton and chat domains; `apply.ts` * owns their slot assembly. */ +export type {} from './conversation-nodes/assistant.ts' +export type {} from './conversation-nodes/command.ts' +export type {} from './conversation-nodes/compaction.ts' +export type {} from './conversation-nodes/fallback.ts' +export type {} from './conversation-nodes/message.ts' +export type {} from './conversation-nodes/retry.ts' +export type {} from './conversation-nodes/tool.ts' +export type {} from './conversation-nodes/turn-error.ts' +export type {} from './conversation-nodes/turn-tail.ts' + export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' -export { registerAssistantConversationNode } from './conversation-nodes/assistant.ts' -export { registerChatConversationView } from './conversation-nodes/chat-snapshot-builder.ts' -export { registerCommandConversationNode } from './conversation-nodes/command.ts' -export { registerCompactionConversationNode } from './conversation-nodes/compaction.ts' -export { registerUnknownConversationFallback } from './conversation-nodes/fallback.ts' -export { registerInboxConversationNodes } from './conversation-nodes/inbox.ts' -export { registerMessageConversationNode } from './conversation-nodes/message.ts' -export { registerRetryConversationNode } from './conversation-nodes/retry.ts' -export { registerToolConversationNode } from './conversation-nodes/tool.ts' -export { registerTurnErrorConversationNode } from './conversation-nodes/turn-error.ts' -export { registerTurnTailConversationNode } from './conversation-nodes/turn-tail.ts' export type { IConversation } from './service.ts' export type { diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index 3ae906087e..5346ad57f9 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -16,7 +16,7 @@ import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts' import type { - AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, + AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData, } from '../src/client/contract/chat-nodes.ts' const DEFINITIONS: readonly ConversationNodeDefinition[] = [ @@ -413,6 +413,30 @@ describe('built-in conversation node Definitions', () => { ]) }) + it('keeps branching unavailable when a tool result follows the closing Assistant', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-before-tool', 'running a tool'), + }, { surfaceOp: 'append' }), + at(4, 'tool/call', { turn: 1, step: 1, callId: 'late-tool', name: 'read', arguments: '{}' }), + at(5, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('late-tool', 'done'), + }, { surfaceOp: 'append' }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + + const tail = node(snapshot(value), 'turn-tail')?.data as TurnTailChatData + expect(tail.closing?.finalNode.seq).toBe(3) + expect(tail.branchUnavailable).toBe(true) + }) + it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => { const value = assembler([ at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }), diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 51fdc06f61..662ad1df13 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -121,6 +121,8 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface', command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface', + conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface', + conversationViews: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface', layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the surface', locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the surface', models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface', From 990f700d3cd4c977a5cb1788ef7c9c5d138f918a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:28:42 +0800 Subject: [PATCH 10/32] fix(client): address conversation assembly review feedback --- ...lient-conversation-node-assembly.i18n.yaml | 4 ++-- ...08-09-client-conversation-node-assembly.md | 10 ++++---- ...09-client-conversation-node-assembly.zh.md | 10 ++++---- packages/client/runtime/README.i18n.yaml | 4 ++-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../client/sessions/conversation-assembler.ts | 2 ++ .../src/client/sessions/conversation.ts | 24 ++++++++++++------- .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 4 ++-- packages/client/ui-conversation/README.zh.md | 4 ++-- .../src/client/contract/slots.ts | 9 ++++--- .../client/conversation-nodes/assistant.ts | 4 ++-- .../chat-snapshot-builder.ts | 5 +++- .../src/client/conversation-nodes/common.ts | 10 ++++++++ .../src/client/conversation-nodes/tool.ts | 4 ++-- .../client/conversation-nodes/turn-tail.ts | 8 ++++--- packages/client/ui-tool/README.i18n.yaml | 4 ++-- packages/client/ui-tool/README.md | 4 ++-- packages/client/ui-tool/README.zh.md | 4 ++-- 20 files changed, 73 insertions(+), 49 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 953a049744..264b900b74 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 0cee5449f651c568dbb87b9cb867eb77b6380184 -2026-08-09-client-conversation-node-assembly.zh.md: 146e8f68a9b1339934040cee62fd46e981145f2a +2026-08-09-client-conversation-node-assembly.md: f9768a652c7b0d2d29939fac83b201bc904e3210 +2026-08-09-client-conversation-node-assembly.zh.md: 408d61f73b3b317214cb10b26f522e7680de3f92 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 0cee5449f6..f9768a652c 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -148,7 +148,7 @@ The Assembler verifies `node.key === context.key` and `node.target === target`. `current` lets a Definition distinguish "never materialized" from "already materialized and now hidden." Assistant retry and Turn Error suppression use it to avoid illegal Node withdrawal. -A Definition may branch by target to construct different data, while matching, Context identity, and State remain target-neutral. This change registers only the `chat` builder; Trajectory continues to consume the compatibility slice. +A Definition may branch by target to construct different data, while matching, Context identity, and State remain target-neutral. This change registers only the `chat` builder; Trajectory remains on its independent `session-history` fold until it gains a registered target. #### No generic `end()` @@ -306,7 +306,7 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface The Assembler calls `replace({ nodes, timeline })` on low-frequency complete replacements and `apply({ upserts, timeline })` for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions. -[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice temporarily consumed by Trajectory. +[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value. @@ -328,7 +328,7 @@ When business logic deliberately changes a materialized Node to hidden, it leave The concrete Tool renderer remains governed by the [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md). Tool Definition supplies recursive root/subcall data, and `ui-tool` dispatches concrete presentation by the Tool-name keyed slot. -Trajectory has no independent registered target yet. It continues to consume the legacy slice incrementally derived by the Chat Builder, while Session no longer runs a second transcript fold; a future migration does not change the Event Definition, Context, Reader, or Location contracts. +Trajectory has no registered target and does not consume the Chat Builder's legacy slice. Its activated `SessionHistoryInspection` keeps an independent history fold, while the ordinary Session snapshot no longer runs a second transcript fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; a future Trajectory migration does not change the Event Definition, Context, Reader, or Location contracts. ## Runtime and render path @@ -382,7 +382,7 @@ History-path tests cover complete replace, non-overlapping prepend, overlapping- **Add generic `end()`, prepared, or window-reset lifecycles.** Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation. -**Register separate Event Definitions for Chat and Trajectory.** Rejected: identity, State, and Location are target-neutral. `buildViewNode(target)` and each Builder express view differences; Trajectory retains a compatibility slice until its actual migration. +**Register separate Event Definitions for Chat and Trajectory.** Rejected: identity, State, and Location are target-neutral. `buildViewNode(target)` and each Builder express view differences; Trajectory's independent history fold remains until it registers its own Builder. **Add a generic layout model above final business Nodes.** Rejected: activity, tail candidacy, and layout enums would centralize current Chat business semantics in the engine again. Final Nodes carry renderer-required data directly and share only identity, ordering, and Location facts. @@ -404,4 +404,4 @@ Steps and Turns become stable homes for cross-business aggregates. Turn Tail and The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definition authors must understand stable IDs, unique starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal. -`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice until Trajectory migrates; built-in Definitions currently remain centralized in `ui-conversation`. These compatibility boundaries do not return business interpretation to Session. +`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, Trajectory still owns an independent history fold, and built-in Definitions currently remain centralized in `ui-conversation`. These compatibility boundaries do not return business interpretation to Session. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 146e8f68a9..408d61f73b 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -148,7 +148,7 @@ Assembler 校验 Node `key === context.key` 且 Node `target === target`。业 `current` 让 Definition 区分“从未生成”与“已经生成后需要隐藏”。Assistant retry 和 Turn Error suppression 使用它避免非法的 Node 撤回。 -Definition 可以针对 target 分支构造不同 data,但匹配、Context identity 和 State 保持 target-neutral。本次只注册 `chat` builder,Trajectory 仍通过兼容 slice 使用结果。 +Definition 可以针对 target 分支构造不同 data,但匹配、Context identity 和 State 保持 target-neutral。本次只注册 `chat` builder;在拥有注册 target 之前,Trajectory 继续使用独立的 `session-history` fold。 #### 不提供通用 `end()` @@ -306,7 +306,7 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何 Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。 -[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline` 和 Trajectory 临时使用的 `legacy` slice。 +[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value。 @@ -328,7 +328,7 @@ Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat 具体 Tool renderer 仍由 [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md) 约束。Tool Definition 只交付递归 root/subcall data,`ui-tool` 再按 Tool name keyed slot 分发具体表现。 -Trajectory 尚未注册独立 target。它继续消费 Chat Builder 增量派生的 legacy slice,Session 不再运行第二套 transcript fold;未来迁移不改变 Event Definition、Context、Reader 或 Location 契约。 +Trajectory 尚未注册 target,也不消费 Chat Builder 的 legacy slice。它已激活的 `SessionHistoryInspection` 继续维护独立 history fold,而普通 Session snapshot 不再运行第二套 transcript fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;未来迁移 Trajectory 不改变 Event Definition、Context、Reader 或 Location 契约。 ## Runtime and render path @@ -382,7 +382,7 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏 **增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 State,Location close 触发 replay/build,Reader dependency 负责补页失效。 -**为 Chat 与 Trajectory 注册两套 Event Definition。** 拒绝:identity、State 和 Location 与 target 无关。视图差异由 `buildViewNode(target)` 和各自 Builder 表达;Trajectory 在真正迁移前保留兼容 slice。 +**为 Chat 与 Trajectory 注册两套 Event Definition。** 拒绝:identity、State 和 Location 与 target 无关。视图差异由 `buildViewNode(target)` 和各自 Builder 表达;Trajectory 在注册自己的 Builder 之前继续使用独立 history fold。 **在最终业务 Node 上再叠一层通用 layout model。** 拒绝:activity、tail candidacy 和 layout enum 会把当前 Chat 的业务语义重新集中到引擎。最终 Node 直接携带 renderer 所需 data,只共享 identity、排序和 Location 事实。 @@ -404,4 +404,4 @@ Step/Turn 成为业务间共享聚合的稳定宿主。Turn Tail 和 Deliverable 代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。Definition 作者必须理解稳定 ID、唯一 start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。 -`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 在 Trajectory 迁移前仍维护 legacy slice;内建 Definitions 暂时集中在 `ui-conversation`。这些是兼容边界,不把业务解释权交还给 Session。 +`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 继续拥有独立 history fold,内建 Definitions 暂时集中在 `ui-conversation`。这些是兼容边界,不把业务解释权交还给 Session。 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 4aed1d0f36..d47266cd8a 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/client/runtime/README.md -README.md: b2bb06e50ecd791d74cb609404dd219e5a21913e -README.zh.md: 72cd99ac765875c828cc9163d978e0b8fb7f44e4 +README.md: 46a383f22032c8da7e4bb9b5fb445980661c670f +README.zh.md: 73578a8f249472abfdd7c539e3eaee6de767ad60 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index b2bb06e50e..46a383f220 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -38,7 +38,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair. -`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. `ConversationSnapshot.nodes`, `partial`, and `runningCalls` are compatibility slices derived from the same materialized Chat nodes for consumers that have not moved to `ConversationSnapshot.chat`; Session does not run a second business fold. +`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target. The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 72cd99ac76..73578a8f24 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -38,7 +38,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。 -`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。`ConversationSnapshot.nodes`、`partial` 和 `runningCalls` 是从同一批已物化 Chat 节点派生的兼容切片,供尚未迁移到 `ConversationSnapshot.chat` 的消费者使用;Session 不再运行第二套业务 fold。 +`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。 Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。 diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index 3a0c94ba3c..811bd43ebf 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -743,6 +743,8 @@ export class ConversationNodeAssembler { context.locationData[scope] = data if (data !== null) entries.push({ owner: context.key, data }) } + // Turn publishers may read Step data from this same flush, so each phase + // installs the cumulative replacement before the next phase builds. this.locationIndex.replaceData(entries) } } diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 99daa7c8a5..628fe89aa8 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -1,7 +1,9 @@ // ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI. -// Immutability contract: every change swaps the top-level object; unchanged -// substructures keep their references (the React.memo premise). callId/approvalId stay plain -// string here (narrow to real brands when convenient). +// Publication contract: every change swaps the top-level object; unchanged +// substructures keep their references (the React.memo premise). Chat node and +// Location stores are stable live readers, so old snapshots are not time-point +// views. callId/approvalId stay plain string here (narrow to real brands when +// convenient). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -336,7 +338,10 @@ export interface PromptError { error: RpcError } -/** Stable per-key reader for final Chat view Nodes. */ +/** + * Stable live per-key reader. An old ChatSnapshot observes later flushes + * through this store. + */ export interface ChatNodeStore { /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ get(key: string): ChatConversationViewNode | undefined @@ -344,7 +349,10 @@ export interface ChatNodeStore { values(): readonly ChatConversationViewNode[] } -/** Stable per-Location membership index for turn-local and step-local consumers. */ +/** + * Stable live Location index. An old ChatSnapshot observes later membership + * changes through this index. + */ export interface ChatLocationNodeIndex { /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ getTurn(turn: number): readonly string[] @@ -352,7 +360,7 @@ export interface ChatLocationNodeIndex { getStep(turn: number, step: number): readonly string[] } -/** Temporary projection consumed by Trajectory and unmigrated readers. */ +/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */ export interface LegacyConversationSlice { readonly nodes: readonly ConversationNode[] readonly turnTimings: ReadonlyMap @@ -361,7 +369,7 @@ export interface LegacyConversationSlice { readonly runningCalls: readonly RunningToolCall[] } -/** Incremental Chat target snapshot: stable keyed stores plus structural order. */ +/** Incremental Chat publication with immutable order and stable live keyed readers. */ export interface ChatSnapshot { readonly order: readonly string[] readonly nodes: ChatNodeStore @@ -399,7 +407,7 @@ export interface ConversationSnapshot { sessionId: SessionId /** Final Chat target assembled from independently registered business Definitions. */ chat: ChatSnapshot - /** Legacy Trajectory slice derived from the registered Chat Definitions. */ + /** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */ nodes: readonly ConversationNode[] /** Exact in-window `turn/start` time and optional matching `turn/end` time. */ turnTimings: ReadonlyMap diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2066b22ec5..bb15c65029 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 911bca28dcfb31b1d8ef9ea5458a0d2017d8b31d -README.zh.md: 25574421bbcc3992188e378a49a69acf25744af7 +README.md: 3570e814609af0bc4e7d048a55205af756fae843 +README.zh.md: e37f867c72d83b47924e87261e6ee5ff8b374a6f diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 911bca28dc..3570e81460 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -20,7 +20,7 @@ Logged non-user messages render as a default-collapsed disclosure whose header n A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). -The chat view keeps Tool placement but delegates Tool presentation. It passes each ordered root call through `conversation.chat.tool`, and the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle fills the whole-Tool seat with [`ui-tool`](../ui-tool/README.md), which selects Runtime-projected Code Dispatch children and owns root/child composition, per-name dispatch, generic rendering, and render-intent cards; the details seat alone retains a raw-result fallback when that renderer is absent. +The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. @@ -40,7 +40,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations. -A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` contract — an absent service leaves the prose inert. +A finished turn materializes one ordered `turn-tail` Conversation Node. Its engine-owned `TurnLocation` supplies the closing Assistant and Turn data; the renderer places the `conversation.chat.turnTail` chain before that node's IconActions and dispatches `TurnTailOwnerProps` containing the Turn, closing seq, and `openFile`. This package owns only the hole; `@deepseek-ai/dsh-client-ui-deliverables` accumulates mutation-tool `locations` into Turn data and owns the produced-files row, chip cap, and copy, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 25574421bb..e37f867c72 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,7 +18,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 -聊天视图保留 Tool 的消息流位置,但委托其展示。它通过 `conversation.chat.tool` 传递每个已排序的 root call;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 由 [`ui-tool`](../ui-tool/README.md) 填充整体 Tool 席位,并由后者选择 Runtime 已投影的 Code Dispatch 子调用,负责 root/child 编排、按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。 +聊天视图保留 Tool 的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染 Runtime 已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 @@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。 -完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` 约定——service 缺席时正文保持死文本。 +完成的一轮会物化一个有序的 `turn-tail` Conversation Node。它由引擎维护的 `TurnLocation` 提供收尾 Assistant 和 Turn data;renderer 在该 Node 的 IconActions 之前渲染 `conversation.chat.turnTail` chain,并派发包含 Turn、收尾 seq 和 `openFile` 的 `TurnTailOwnerProps`。本包只拥有空位;`@deepseek-ai/dsh-client-ui-deliverables` 把改写工具的 `locations` 累积到 Turn data,并拥有产物行、chip 上限和文案,因此把该插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 474e7f9db5..eec5450e2d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -56,11 +56,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } /** - * The chat view's turn-tail chain: rendered between a closing assistant - * message's body and its IconActions footer, once per turn (the render - * site elects the closing seq). Entries derive a match from the owner - * currency before mounting, so presentation components never mount only - * to return null; an all-declined chain renders nothing. + * The completed Turn Node's extension chain, rendered before that Node's + * IconActions. Entries derive a match from the engine-owned Turn and + * closing seq before mounting, so presentation components never mount + * only to return null; an all-declined chain renders nothing. */ 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } /** Selected Tool call output inside the details panel. */ diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 2bdf960226..8a487c89ed 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -7,7 +7,7 @@ import { emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks, } from '@deepseek-ai/dsh-client-runtime/client' import type { AssistantChatData } from '../contract/chat-nodes.ts' -import { chatNode } from './common.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { @@ -169,7 +169,7 @@ function finalNode( if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined return { kind: 'assistant', - seq: boundary.seq - 0.9, + seq: boundary.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedAssistant, time: boundary.time, turn: state.turn, step: state.step, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index f9491d2d16..f417c33b75 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -154,6 +154,9 @@ const EMPTY_CONTRIBUTION: LegacyContribution = { function legacyContribution(raw: ChatConversationViewNode): LegacyContribution { const node = raw as ChatNode + // Content-free settled Assistants remain in the finalized compatibility + // stream so StatsLine preserves its pre-assembly step counts; hidden running + // attempts have no final Node to contribute. if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION switch (node.kind) { case 'user': @@ -221,7 +224,7 @@ function sameContribution(left: LegacyContribution | undefined, right: LegacyCon && sameReferences(left.nodes, right.nodes) } -/** Incremental compatibility projection retained solely for unmigrated Trajectory consumers. */ +/** Incremental compatibility projection for StatsLine and legacy top-level snapshot fields. */ class LegacySliceBuilder { private readonly contributions = new Map() private readonly finalizedContributions = new Map() diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts index 8e1d9d7bd4..b6bd01930b 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts @@ -5,6 +5,16 @@ import type { ChatNode, ChatNodeDataMap, ChatNodeKind, } from '../contract/chat-nodes.ts' +/** + * Relative positions in one durable event's seq neighborhood: interrupted + * Assistant, its follow-up Nodes, then follow-ups to an ordinary final. + */ +export const CHAT_SYNTHETIC_SEQ_OFFSETS = { + interruptedAssistant: -0.9, + interruptedFollowup: -0.8, + finalizedFollowup: 0.1, +} as const + /** * Resolve one Context's best currently loaded event Location. * @param context - assembled business Context. diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts index 04a5770f5a..23201fec3e 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -5,7 +5,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolChatData } from '../contract/chat-nodes.ts' -import { chatNode } from './common.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { @@ -190,7 +190,7 @@ function projectBlock( ? sameReferences(block.subCalls, children) ? block : { ...block, subCalls: children } : { kind: 'tool-result', - seq: interruptedAt.seq - 0.8, + seq: interruptedAt.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup, time: interruptedAt.time, callId: block.callId, call: { name: block.name, argsRaw: block.argsRaw }, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts index 346bd41bdf..51cb348e6a 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -7,7 +7,7 @@ import type { AssistantChatData, FinalAssistantChatData, TurnTailChatData, } from '../contract/chat-nodes.ts' import { deriveTurnMetrics } from '../chat/turn-metrics.ts' -import { chatNode } from './common.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { @@ -85,7 +85,9 @@ function closingAnchor(context: ConversationNodeContext): number } if (event.type === 'assistant/message') { steps.set(coordinates.step, { streamedText: false, finalized: true }) - if (hasTextAssistant(event)) anchor = event.seq + 0.1 + if (hasTextAssistant(event)) { + anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup + } continue } if ((event.type as string) === 'llm/retry') { @@ -93,7 +95,7 @@ function closingAnchor(context: ConversationNodeContext): number continue } if (event.type === 'step/end' && previous.streamedText && !previous.finalized) { - anchor = event.seq - 0.8 + anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup } } return anchor diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index ca5f3cd25c..84c6fa42af 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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/client/ui-tool/README.md -README.md: d6bc0f248cffaf4c65ecf6d97c7a4afb17fc8941 -README.zh.md: 6b6e2a153be6a3cad5b57379de6fdc0cd4a58ea4 +README.md: b33aef86a6ad161f70105968f308c5a587c25c24 +README.zh.md: 680a5149187bc43513c74134a9a58c4680af854c diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index d6bc0f248c..b33aef86a6 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client Tool presentation plugin. `ui-conversation` supplies one ordered root call through `conversation.chat.tool`; this package renders that root and its Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card. +Client Tool presentation plugin. `ui-conversation` dispatches each ordered `tool-call` Conversation Node through the matching key of `conversation.chat.node`; this package renders its root and Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card. Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and recursive `subCalls` projection; the conversation view remains authoritative for ChatFlow placement. @@ -10,7 +10,7 @@ Business UI packages register only their wire Tool names and atomic views. They `ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map. -Each root and child wrapper preserves the `conversation.chat.tool` call-anchor DOM contract used for paging and selection. +Each root and child wrapper preserves the `data-chat-anchor-key="call:"` and `data-chat-call-id` DOM contract used for paging and selection. The package also fills `conversation.details.tool` with `ToolDetails`. The row and details renderers share the same pure card models for `terminal`, `read`, `diff`, `search`, and `web` render intents. Unknown intent tags and malformed wire card data fall back to flattened Tool result text. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 6b6e2a153b..680a514918 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call;本包渲染该 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。 +Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.node` 的同名 key 分发每个已排序的 `tool-call` Conversation Node;本包渲染其中的 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。 业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和递归 `subCalls` 投影;conversation view 继续负责 ChatFlow 位置。 @@ -10,7 +10,7 @@ Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交 `ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它递归遍历标准 call block,让 root 与任意深度的 child 经过同一条原子分发路径,不再订阅独立的 parent-to-children map。 -每个 root 和 child wrapper 都保留 `conversation.chat.tool` 的 call-anchor DOM 约定,供分页和 selection 使用。 +每个 root 和 child wrapper 都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。 本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与详情 renderer 为 `terminal`、`read`、`diff`、`search` 和 `web` render intent 共用同一组纯 card model。本版本不认识的 intent 标签和格式错误的 wire card 数据都会回退为压平的 Tool result 文本。 From 3d70889a8d7f19de7c4dc2ede606fe1c8bc618d9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:53:09 +0800 Subject: [PATCH 11/32] refactor(session): unify event map augmentation entrypoint --- packages/compact/compact/src/types.ts | 2 +- packages/core/agent/src/types.ts | 2 +- packages/core/session/tests/fork.spec.ts | 2 +- .../session/tests/gen-persistence-catalog.spec.ts | 2 +- packages/core/tools/src/code-mode.ts | 2 +- packages/feedback/command-feedback/src/index.ts | 2 +- packages/goal/goal/src/domain.ts | 2 +- packages/hooks/hook-protocol/src/types.ts | 2 +- packages/interaction/commands/src/index.ts | 2 +- packages/interaction/permission/src/index.ts | 2 +- packages/interaction/user-approval/src/index.ts | 2 +- packages/llm/llm-retry/src/index.ts | 13 ++----------- packages/llm/llm-retry/src/types.ts | 9 +++++++++ packages/plan/plan-mode/src/index.ts | 2 +- packages/sandbox/sandbox-policy/src/session-mode.ts | 2 +- .../session-projection-cache/tests/cache.spec.ts | 2 +- .../session-projection/tests/registry.spec.ts | 2 +- .../session-telemetry/tests/telemetry.spec.ts | 2 +- packages/session/session-title-llm/src/index.ts | 2 +- packages/session/session-title/src/index.ts | 2 +- packages/subagent/subagent/src/descriptor.ts | 2 +- packages/web/web-search-deepseek/src/provider.ts | 2 +- 22 files changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index c321be426e..df5e749cd8 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -11,7 +11,7 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CompactionId } from './brand.ts' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Marks the start of a compaction — log-only, holds the lock until diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d21f25cdc7..8decb9ba3f 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -290,7 +290,7 @@ declare module 'cordis' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * One normalized mutation of an agent's durable pending-message lists. diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 0e7a0629c3..58985cfb34 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -4,7 +4,7 @@ import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { 'test/log-only': { value: string } /** Stands in for a plugin's open/close bracket (`compact/start`). */ diff --git a/packages/core/session/tests/gen-persistence-catalog.spec.ts b/packages/core/session/tests/gen-persistence-catalog.spec.ts index 9a09cb3489..22bc09835f 100644 --- a/packages/core/session/tests/gen-persistence-catalog.spec.ts +++ b/packages/core/session/tests/gen-persistence-catalog.spec.ts @@ -35,7 +35,7 @@ const make = (files: Record): string => { /** A merge-form declaration file wrapping `members` in the session module. */ const merge = (members: string): string => - `declare module '@deepseek-ai/dsh-session' {\n interface SessionEventMap {\n${members}\n }\n}\n` + `declare module '@deepseek-ai/dsh-session/types' {\n interface SessionEventMap {\n${members}\n }\n}\n` afterEach(() => { while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 63c5b2cb9b..984fecab88 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -15,7 +15,7 @@ import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts' import { TOOL_REGISTRY_SCHEDULER } from './index.ts' import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * One sub-dispatch STARTING inside a `run_code` program: the parent diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index ae78b3cd4f..037a463104 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -15,7 +15,7 @@ export const inject = ['commands'] const USAGE = 'Usage: /feedback ' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * One recorded human remark about this session. Log-only and independent diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index 8c8c4de6b6..90f22a1602 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -58,7 +58,7 @@ declare module '@deepseek-ai/dsh-llm' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Complete post-mutation goal state or clear tombstone. diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index 82a67f9b93..adaafd6b0f 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-hook-protocol/types */ -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * A hook command was invoked at a hook point — a log-only record (like diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 780569c159..ba7b449228 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -131,7 +131,7 @@ class CommandLayer implements ScopeLayer { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * A resolved slash command entered its handler. Log-only (never model diff --git a/packages/interaction/permission/src/index.ts b/packages/interaction/permission/src/index.ts index 44d62a9c33..35b762b2aa 100644 --- a/packages/interaction/permission/src/index.ts +++ b/packages/interaction/permission/src/index.ts @@ -39,7 +39,7 @@ declare module 'cordis' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Records the selected preset as durable, log-only user intent. The knob diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index 3dfeabe570..70eacaa627 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -31,7 +31,7 @@ declare module 'cordis' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * An approval question was put to the answerer chain — log-only audit diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index dcba1e2443..2ebeb79260 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -12,16 +12,7 @@ import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { RetryId } from './brand.ts' -import type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' - -declare module '@deepseek-ai/dsh-session' { - interface SessionEventMap { - /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ - 'llm/retry': LlmRetryEventData - /** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ - 'llm/retry-started': LlmRetryStartedEventData - } -} +import type { LlmRetryEventData } from './types.ts' export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' export { RetryId } from './brand.ts' @@ -132,7 +123,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna ): Promise { const fusedSignal = AbortSignal.any([signal, lifetime.signal]) if (fusedSignal.aborted) return - const eventData = policy.mode === 'normal' + const eventData: LlmRetryEventData = policy.mode === 'normal' ? { retryId, turn, diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts index 12820889dd..c698b68446 100644 --- a/packages/llm/llm-retry/src/types.ts +++ b/packages/llm/llm-retry/src/types.ts @@ -1,6 +1,15 @@ import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' import type { RetryId } from './brand.ts' +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ + 'llm/retry': LlmRetryEventData + /** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ + 'llm/retry-started': LlmRetryStartedEventData + } +} + /** Durable payload recorded before one provider-routed model-request retry wait. */ export type LlmRetryEventData = | { diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index c8dd974fd2..00234424ff 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -42,7 +42,7 @@ import type { PlanProjection } from './types.ts' // declarations still receive the SessionProjectionMap merge. export type * from './types.ts' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Whether plan mode is in force from this point on: log-only, non-surface, diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index fc7c53938c..165a462046 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -21,7 +21,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * The session's sandbox mode was switched — log-only (like `approval/*`; diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 8474e21594..ba9dc39c57 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -24,7 +24,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { 'cache-test/mark': { marks: string[] } } diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index bd33914b2d..e54f1f2468 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -22,7 +22,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { 'test/mark': { marks: string[] } } diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index 31e587c301..029381fe78 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -17,7 +17,7 @@ import { type TelemetryRecord, } from '../src/index.ts' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Test-only merged event proving unknown types flow through unchanged. diff --git a/packages/session/session-title-llm/src/index.ts b/packages/session/session-title-llm/src/index.ts index 1b0e328621..52572db30d 100644 --- a/packages/session/session-title-llm/src/index.ts +++ b/packages/session/session-title-llm/src/index.ts @@ -37,7 +37,7 @@ export interface SessionTitleLlmRequestEventData { readonly maxTokens: number } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** Log-only pre-dispatch record of one session-title model request. */ 'session/title-llm-request': SessionTitleLlmRequestEventData diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 12ca7924b4..6f8594edd4 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -91,7 +91,7 @@ declare module 'cordis' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Latest-wins session title snapshot. Log-only: it never enters the model diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 55ba73dcc4..6d9dedee75 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -25,7 +25,7 @@ import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Durable identity and lifecycle mode of a session-backed subagent child, diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 4bacd80fec..f88941fa12 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -77,7 +77,7 @@ export interface DeepSeekSearchLlmRequest { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** Secret-free auxiliary DeepSeek search request recorded before dispatch. */ 'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest From 10464d155def7c3cdc22459e6bfe824cc118a10a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:42:25 +0800 Subject: [PATCH 12/32] fix(client): consume typed business session events --- ...08-09-client-conversation-node-assembly.md | 2 +- ...09-client-conversation-node-assembly.zh.md | 2 +- packages/client/runtime/package.json | 2 + .../sessions/conversation-location-index.ts | 12 +- .../src/client/sessions/request-inspection.ts | 108 ++++++------------ .../src/client/sessions/steering-history.ts | 5 +- .../src/client/sessions/tool-call-tree.ts | 22 +--- packages/client/runtime/tsconfig.json | 6 + packages/client/ui-conversation/package.json | 8 ++ .../client/conversation-nodes/assistant.ts | 10 +- .../src/client/conversation-nodes/command.ts | 72 +++++------- .../client/conversation-nodes/compaction.ts | 14 +-- .../src/client/conversation-nodes/inbox.ts | 13 ++- .../src/client/conversation-nodes/retry.ts | 58 +++------- .../src/client/conversation-nodes/tool.ts | 31 ++--- .../client/conversation-nodes/turn-error.ts | 7 +- .../client/conversation-nodes/turn-tail.ts | 9 +- .../ui-conversation/tests/chat-apply.spec.tsx | 4 +- .../conversation-node-definitions.spec.ts | 22 ++++ packages/client/ui-conversation/tsconfig.json | 12 ++ packages/compact/compact/package.json | 8 +- packages/compact/compact/src/types.ts | 4 +- packages/core/agent/package.json | 4 +- packages/core/agent/src/dispatch.ts | 2 +- packages/core/agent/src/inbox.ts | 4 +- packages/core/agent/src/index.ts | 5 +- .../agent/src/{types.ts => runtime-types.ts} | 22 +--- packages/core/agent/src/session-types.ts | 27 +++++ .../tests/gen-persistence-catalog.spec.ts | 2 +- packages/core/tools/package.json | 4 + packages/core/tools/src/code-mode.ts | 36 +----- packages/core/tools/src/index.ts | 1 + packages/core/tools/src/types.ts | 58 ++++++++++ packages/interaction/commands/package.json | 4 + packages/interaction/commands/src/index.ts | 42 +------ packages/interaction/commands/src/types.ts | 48 ++++++++ packages/llm/llm-retry/package.json | 4 - packages/llm/llm-retry/src/types.ts | 4 +- .../llm/llm-retry/tests/invariant.spec.ts | 2 +- .../llm/llm-retry/tests/persistence.spec.ts | 2 +- .../context-breakdown-projection.spec.ts | 2 +- .../tests/token-usage-projection.spec.ts | 2 +- .../llm-replay/tests/llm-replay.spec.ts | 2 +- pnpm-lock.yaml | 18 +++ scripts/gen-persistence-catalog.ts | 19 +-- scripts/type-equiv.manifest.json | 16 +-- tsconfig.base.json | 3 + 47 files changed, 405 insertions(+), 359 deletions(-) rename packages/core/agent/src/{types.ts => runtime-types.ts} (96%) create mode 100644 packages/core/agent/src/session-types.ts create mode 100644 packages/core/tools/src/types.ts create mode 100644 packages/interaction/commands/src/types.ts diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index f9768a652c..9952f8fac0 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -16,7 +16,7 @@ Business events also use different correlation models. Tool has call IDs, Assist Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous Event window to the engine and publishes its snapshot instead of interpreting individual conversation businesses. -The complete derivation, business-by-business validation, and file-level implementation plan remain in [`business-node assembler version one`](../../../../docs/client-conversation-node-engine-rfc.md), [`follow-up design differences`](../../../../docs/client-conversation-node-engine-follow-up-differences.md), [`business-node and dual-view adaptation analysis`](../../../../docs/client-conversation-node-adaptation-analysis.md), and the [`Chat implementation design`](../../../../docs/client-conversation-node-chat-implementation-plan.md). Those design documents retain the full discussion; this Note fixes the responsibilities, algorithms, and trade-offs that remain relevant after implementation. +This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation. ### Responsibility layers diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 408d61f73b..f27a82459c 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -16,7 +16,7 @@ Client Session 既维护传输窗口、连接状态和待处理交互,也在 Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builder;Session 只负责把当前连续事件窗口送入引擎并发布它的 snapshot,不再解释具体 conversation 业务。 -详细的方案推导、逐业务适配和逐文件实施设计保留在 [`业务节点组装器第一版`](../../../../docs/client-conversation-node-engine-rfc.md)、[`后续方案差异`](../../../../docs/client-conversation-node-engine-follow-up-differences.md)、[`业务节点与双视图适配论证`](../../../../docs/client-conversation-node-adaptation-analysis.md) 和 [`Chat 链路工程实施设计`](../../../../docs/client-conversation-node-chat-implementation-plan.md)。这些设计稿保留完整讨论过程;本 Note 固定实现后仍需长期维护的职责、算法和取舍。 +本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。 ### 责任分层 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 3176428b36..f5a9a06a60 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -32,6 +32,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "immer": "^10.1.1", "react": "^18.2.0", "zustand": "~4.4.7" diff --git a/packages/client/runtime/src/client/sessions/conversation-location-index.ts b/packages/client/runtime/src/client/sessions/conversation-location-index.ts index 20fe3efdff..c842a230a9 100644 --- a/packages/client/runtime/src/client/sessions/conversation-location-index.ts +++ b/packages/client/runtime/src/client/sessions/conversation-location-index.ts @@ -139,7 +139,11 @@ export class ConversationLocationIndex { return this.timeline } - /** Replace all Definition-owned Location values while preserving reader identities. */ + /** + * Replace all Definition-owned Location values while preserving reader identities. + * @param entries - complete current set of Definition-owned Location values. + * @returns whether any published Location data changed. + */ replaceData(entries: readonly { readonly owner: string; readonly data: ConversationLocationData }[]): boolean { const turns = new Map>() const steps = new Map>() @@ -165,7 +169,11 @@ export class ConversationLocationIndex { return changed } - /** Apply changed Context publications without rebuilding Turn/Step membership. */ + /** + * Apply changed Context publications without rebuilding Turn/Step membership. + * @param changes - incremental removals and replacements from published Contexts. + * @returns whether any published Location data changed. + */ applyData(changes: readonly ConversationLocationDataChange[]): boolean { let changed = false for (const change of changes) { diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 36ae79eebe..162f34d5ff 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -5,6 +5,9 @@ import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-compact/types' +import type {} from '@deepseek-ai/dsh-llm-retry/types' +import type {} from '@deepseek-ai/dsh-tools/types' import type { AssistantProvenanceView, AssistantRequestConfig, } from './conversation.ts' @@ -109,48 +112,6 @@ export function inspectRequests( } } -interface RetryEvent { - type: 'llm/retry' - seq: number - time: number - data: { - turn: number - step: number - retry: number - maxRetries: number - delayMs: number - failure: { message: string } - } -} - -interface CompactionStartEvent { - type: 'compact/start' - seq: number - time: number - data: { turn: number | null } -} - -interface CompactionSummaryEvent { - type: 'compact/summary' - seq: number - time: number - data: { - summary: readonly ContentBlock[] - rawOutput?: readonly ContentBlock[] - provider: string - model: string - maxTokens?: number - usage?: unknown - } -} - -interface CompactionEndEvent { - type: 'compact/end' - seq: number - time: number - data: { turn: number | null; error?: string } -} - function requestKey(turn: number, step: number): string { return `${turn}\u0000${step}` } @@ -205,10 +166,8 @@ function deriveCallSchemas( capture(String(event.data.callId), event.data.name) continue } - const type = event.type as string - if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') { - const data = event.data as unknown as { subCallId: string; name: string } - capture(data.subCallId, data.name) + if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { + capture(String(event.data.subCallId), event.data.name) } } return calls @@ -351,14 +310,14 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] if (activeStep === key) activeStep = undefined continue } - if ((sourceEvent.type as string) === 'llm/retry') { - const event = sourceEvent as unknown as RetryEvent - updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), { + if (sourceEvent.type === 'llm/retry') { + const data = sourceEvent.data + updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), { status: 'error', - error: displayFailureMessage(event.data.failure), - retry: event.data.retry, - maxRetries: event.data.maxRetries, - retryDelayMs: event.data.delayMs, + error: displayFailureMessage(data.failure), + retry: data.retry, + ...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}, + retryDelayMs: data.delayMs, }) continue } @@ -374,8 +333,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] continue } - const type = sourceEvent.type as string - if (type === 'session/end-seed' && activeCompaction !== undefined) { + if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) { updateCompaction(activeCompaction, { completedAt: sourceEvent.time, status: 'error', @@ -384,37 +342,36 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] activeCompaction = undefined continue } - if (type === 'compact/start') { - const event = sourceEvent as unknown as CompactionStartEvent + if (sourceEvent.type === 'compact/start') { activeCompaction = requests.length requests.push({ purpose: 'compaction', - startSeq: event.seq, - turn: event.data.turn, + startSeq: sourceEvent.seq, + turn: sourceEvent.data.turn, step: 0, - startedAt: event.time, + startedAt: sourceEvent.time, completedAt: null, status: 'running', }) continue } - if (type === 'compact/summary' && activeCompaction !== undefined) { - const event = sourceEvent as unknown as CompactionSummaryEvent + if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) { + const data = sourceEvent.data updateCompaction(activeCompaction, { - resultSeq: event.seq, - summary: event.data.summary, - ...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }), + resultSeq: sourceEvent.seq, + summary: data.summary, + ...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }), provenance: { - provider: event.data.provider, - model: event.data.model, + provider: data.provider, + model: data.model, }, requestConfig: { - provider: event.data.provider, - model: event.data.model, + provider: data.provider, + model: data.model, purpose: 'compaction', - ...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }), + ...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }), }, - ...(event.data.usage === undefined ? {} : { usage: event.data.usage }), + ...(data.usage === undefined ? {} : { usage: data.usage }), }) continue } @@ -426,12 +383,11 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq }) continue } - if (type !== 'compact/end' || activeCompaction === undefined) continue - const event = sourceEvent as unknown as CompactionEndEvent + if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue updateCompaction(activeCompaction, { - completedAt: event.time, - status: event.data.error === undefined ? 'complete' : 'error', - ...(event.data.error === undefined ? {} : { error: event.data.error }), + completedAt: sourceEvent.time, + status: sourceEvent.data.error === undefined ? 'complete' : 'error', + ...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }), }) activeCompaction = undefined } diff --git a/packages/client/runtime/src/client/sessions/steering-history.ts b/packages/client/runtime/src/client/sessions/steering-history.ts index 0f66025e16..4220a769ff 100644 --- a/packages/client/runtime/src/client/sessions/steering-history.ts +++ b/packages/client/runtime/src/client/sessions/steering-history.ts @@ -1,6 +1,7 @@ /** Reconstruct durable steering identity from the event-sourced agent inbox. */ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-agent/types' type InboxTarget = 'next-turn' | 'next-step' @@ -45,8 +46,8 @@ export class SteeringHistory { * @returns true only for a user-origin message previously claimed from `next-step`. */ apply(event: SessionEvent): boolean { - if ((event.type as string) === 'agent/inbox/spliced') { - this.applySplice(event.data as unknown as InboxSplice) + if (event.type === 'agent/inbox/spliced') { + this.applySplice(event.data) return false } if (event.type !== 'user/message') return false diff --git a/packages/client/runtime/src/client/sessions/tool-call-tree.ts b/packages/client/runtime/src/client/sessions/tool-call-tree.ts index fbc32e0cef..4507b7a947 100644 --- a/packages/client/runtime/src/client/sessions/tool-call-tree.ts +++ b/packages/client/runtime/src/client/sessions/tool-call-tree.ts @@ -1,5 +1,5 @@ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-tools/types' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, } from './conversation.ts' @@ -55,13 +55,8 @@ export class ToolCallTree { * @returns Whether the event was consumed as a child-call lifecycle event. */ apply(event: SessionEvent): boolean { - if ((event.type as string) === 'tool/code-dispatch-start') { - const data = event.data as unknown as { - parentCallId: string - subCallId: string - name: string - arguments: unknown - } + if (event.type === 'tool/code-dispatch-start') { + const data = event.data const running: RunningToolCall = { callId: data.subCallId, name: data.name, @@ -78,15 +73,8 @@ export class ToolCallTree { this.revision++ return true } - if ((event.type as string) !== 'tool/code-dispatch') return false - const data = event.data as unknown as { - parentCallId: string - subCallId: string - name: string - arguments: unknown - isError: boolean - content: ContentBlock[] - } + if (event.type !== 'tool/code-dispatch') return false + const data = event.data const siblings = this.childrenByParent.get(data.parentCallId) ?? [] const at = siblings.findIndex(sub => sub.callId === data.subCallId) if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 867b2f588c..b8949fc6db 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../../interaction/commands" }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, { "path": "../../compact/compact" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 3f5bd8364c..e78567cbf7 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -39,22 +39,28 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-compact": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", @@ -62,9 +68,11 @@ "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 8a487c89ed..83df1e0df6 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -6,6 +6,7 @@ import type { import { emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks, } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { AssistantChatData } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' @@ -197,7 +198,7 @@ function fallbackState(context: ConversationNodeContext): Assist } continue } - if ((match.event.type as string) === 'llm/retry' && state !== undefined) { + if (match.event.type === 'llm/retry' && state !== undefined) { state = resetForRetry(state) } } @@ -247,9 +248,8 @@ export const assistantDefinition: ConversationNodeDefinition = { || (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) { return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } } - if ((event.type as string) === 'llm/retry') { - const data = event.data as unknown as { turn: number; step: number } - return { id: `${data.turn}:${data.step}`, role: 'update' } + if (event.type === 'llm/retry') { + return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } } return null }, @@ -268,7 +268,7 @@ export const assistantDefinition: ConversationNodeDefinition = { usage: match.event.data.usage, } } - if ((match.event.type as string) === 'llm/retry') { + if (match.event.type === 'llm/retry') { return resetForRetry(context.state) } return context.state diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts index 3ca7976e32..38cb85ed87 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -5,6 +5,8 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +import type {} from '@deepseek-ai/dsh-compact/types' +import type {} from '@deepseek-ai/dsh-commands/types' import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' import { chatNode } from './common.ts' @@ -32,21 +34,9 @@ interface CompactionEvidence { readonly checkpoint?: ConversationMatch } -interface CommandRunData { - readonly commandId: CommandId - readonly name: string - readonly args?: string -} - -interface CommandDoneData { - readonly commandId: CommandId - readonly kind: 'success' | 'error' - readonly text?: string - readonly sourceEventSeq?: number -} - function commandFromRun(match: ConversationMatch): CommandNode { - const data = match.event.data as unknown as CommandRunData + if (match.event.type !== 'command/run') throw new Error('command start requires command/run') + const data = match.event.data return { kind: 'command', seq: match.event.seq, @@ -59,10 +49,12 @@ function commandFromRun(match: ConversationMatch): CommandNode { } function commandFromDone(match: ConversationMatch, previous?: CommandNode): CommandNode { - const data = match.event.data as unknown as CommandDoneData + if (match.event.type !== 'command/done') throw new Error('command update requires command/done') + const data = match.event.data const sourceEventSeq = data.kind === 'success' - && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 - ? data.sourceEventSeq as number + && data.sourceEventSeq !== undefined + && Number.isSafeInteger(data.sourceEventSeq) && data.sourceEventSeq >= 0 + ? data.sourceEventSeq : undefined return { kind: 'command', @@ -112,28 +104,21 @@ function compactSummary(match: ConversationMatch | undefined, checkpoint: Conver let summary: string | null = null let shadowedItemCount: number | null = null let shadowedTokenCount: number | null = null - if (match !== undefined) { - const data = match.event.data as unknown as { - summary?: unknown - shadowedSeqs?: unknown - shadowedTokenCount?: unknown - } + if (match?.event.type === 'compact/summary') { + const data = match.event.data if (Array.isArray(data.summary)) { const text = data.summary - .map((block: unknown) => { - const value = block as { type?: unknown; text?: unknown } - return value.type === 'text' && typeof value.text === 'string' ? value.text : '' - }) + .map(block => block.type === 'text' ? block.text : '') .join('') summary = text.trim() === '' ? null : text } shadowedItemCount = Array.isArray(data.shadowedSeqs) - && data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && (seq as number) >= 0) + && data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && seq >= 0) ? data.shadowedSeqs.length : null shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount) - && (data.shadowedTokenCount as number) >= 0 - ? data.shadowedTokenCount as number + && data.shadowedTokenCount >= 0 + ? data.shadowedTokenCount : null } return { @@ -148,9 +133,9 @@ function compactSummary(match: ConversationMatch | undefined, checkpoint: Conver } function fallbackState(context: ConversationNodeContext): CommandState | undefined { - const done = context.matches.find(match => (match.event.type as string) === 'command/done') + const done = context.matches.find(match => match.event.type === 'command/done') const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) - const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + const summary = context.matches.find(match => match.event.type === 'compact/summary') if (checkpoint === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } const source = compactSource(checkpoint.event) if (source?.sourceCommandId === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } @@ -182,7 +167,7 @@ export function updateCompactionState( state: State, match: ConversationMatch, ): State { - if ((match.event.type as string) === 'compact/summary') return { ...state, summary: match } + if (match.event.type === 'compact/summary') return { ...state, summary: match } if (compactSource(match.event) !== undefined) return { ...state, checkpoint: match } return state } @@ -191,27 +176,28 @@ export function updateCompactionState( export const commandDefinition: ConversationNodeDefinition = { kind: 'command', match: (event) => { - if ((event.type as string) === 'command/run') { - return { id: String((event.data as unknown as CommandRunData).commandId), role: 'start' } + if (event.type === 'command/run') { + return { id: String(event.data.commandId), role: 'start' } } - if ((event.type as string) === 'command/done') { - return { id: String((event.data as unknown as CommandDoneData).commandId), role: 'update' } + if (event.type === 'command/done') { + return { id: String(event.data.commandId), role: 'update' } } const checkpoint = compactSource(event) if (checkpoint?.sourceCommandId !== undefined) { return { id: String(checkpoint.sourceCommandId), role: 'update' } } - if ((event.type as string) === 'compact/start' - || (event.type as string) === 'compact/summary' - || (event.type as string) === 'compact/end') { - const data = event.data as unknown as { sourceCommandId?: CommandId } - if (data.sourceCommandId !== undefined) return { id: String(data.sourceCommandId), role: 'update' } + if (event.type === 'compact/start' + || event.type === 'compact/summary' + || event.type === 'compact/end') { + if (event.data.sourceCommandId !== undefined) { + return { id: String(event.data.sourceCommandId), role: 'update' } + } } return null }, start: (_context, match) => ({ command: commandFromRun(match) }), update: (context, match) => { - if ((match.event.type as string) === 'command/done') { + if (match.event.type === 'command/done') { return { ...context.state, command: commandFromDone(match, context.state.command) } } return updateCompactionState(context.state, match) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts index 0742ab6539..04eace8038 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -2,6 +2,7 @@ import type { Context } from 'cordis' import type { CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-compact/types' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' @@ -18,7 +19,7 @@ interface CompactionState { } function fallbackState(context: ConversationNodeContext): CompactionState { - const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + const summary = context.matches.find(match => match.event.type === 'compact/summary') const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) return { ...summary === undefined ? {} : { summary }, @@ -34,12 +35,11 @@ export const compactionDefinition: ConversationNodeDefinition = if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) { return { id: checkpoint.compactionId, role: 'update' } } - if ((event.type as string) === 'compact/start' - || (event.type as string) === 'compact/summary' - || (event.type as string) === 'compact/end') { - const data = event.data as unknown as { compactionId?: unknown; sourceCommandId?: unknown } - if (typeof data.compactionId !== 'string' || data.sourceCommandId !== undefined) return null - return { id: data.compactionId, role: (event.type as string) === 'compact/start' ? 'start' : 'update' } + if (event.type === 'compact/start' + || event.type === 'compact/summary' + || event.type === 'compact/end') { + if (event.data.sourceCommandId !== undefined) return null + return { id: String(event.data.compactionId), role: event.type === 'compact/start' ? 'start' : 'update' } } return null }, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts index 092bcaaa59..74406c324a 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -2,6 +2,7 @@ import type { Context } from 'cordis' import type { ConversationNodeDefinition, ConversationPreviousContext, } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-agent/types' type InboxTarget = 'next-turn' | 'next-step' @@ -41,14 +42,14 @@ function inboxDefinition(target: InboxTarget): ConversationNodeDefinition (event.type as string) === 'agent/inbox/spliced' - && (event.data as unknown as { target?: unknown }).target === target + match: event => event.type === 'agent/inbox/spliced' + && event.data.target === target ? { id: String(event.seq), role: 'start' } : null, - start: (_context, match, reader) => applySplice( - reader.previous(kind), - match.event.data as unknown as InboxSplice, - ), + start: (_context, match, reader) => { + if (match.event.type !== 'agent/inbox/spliced') throw new Error(`${kind} start requires agent/inbox/spliced`) + return applySplice(reader.previous(kind), match.event.data) + }, update: context => context.state, publication: () => 'none', buildViewNode: () => null, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index fe95c0052d..f383bc46d8 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -2,6 +2,7 @@ import type { Context } from 'cordis' import type { ConversationLocation, ConversationNodeDefinition, ModelRetryNode, } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { RetryChatData } from '../contract/chat-nodes.ts' import { chatNode } from './common.ts' @@ -12,11 +13,6 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { } } -type WithoutRetryProjection = Node extends unknown - ? Omit - : never -type RetryEventData = WithoutRetryProjection - /** Accumulated retry attempts sharing one producer-owned RetryId. */ export interface RetryState { readonly turn: number @@ -24,31 +20,14 @@ export interface RetryState { readonly attempts: readonly ModelRetryNode[] } -function retryData(value: unknown): RetryEventData | undefined { - if (value === null || typeof value !== 'object') return undefined - const data = value as Record - if (typeof data.retryId !== 'string' || data.retryId === '' - || !Number.isSafeInteger(data.turn) || (data.turn as number) < 0 - || !Number.isSafeInteger(data.step) || (data.step as number) < 0 - || !Number.isSafeInteger(data.retry) || (data.retry as number) <= 0 - || typeof data.delayMs !== 'number' || !Number.isFinite(data.delayMs) || data.delayMs < 0 - || typeof data.provider !== 'string' || typeof data.policyKey !== 'string' - || (data.mode !== 'normal' && data.mode !== 'always') - || data.failure === null || typeof data.failure !== 'object') return undefined - if (data.mode === 'normal' && (!Number.isSafeInteger(data.maxRetries) || (data.maxRetries as number) <= 0)) { - return undefined - } - return data as unknown as RetryEventData -} - -function scheduledNode(event: { seq: number; time: number; data: unknown }): ModelRetryNode | undefined { - const data = retryData(event.data) - return data === undefined ? undefined : { +function scheduledNode(match: Parameters[1]): ModelRetryNode | undefined { + if (match.event.type !== 'llm/retry') return undefined + return { kind: 'model-retry', - seq: event.seq, - time: event.time, + seq: match.event.seq, + time: match.event.time, retryState: 'scheduled', - ...data, + ...match.event.data, } } @@ -61,33 +40,30 @@ function isClosed(location: ConversationLocation): boolean { export const retryDefinition: ConversationNodeDefinition = { kind: 'model-retry', match: (event) => { - if ((event.type as string) === 'llm/retry') { - const data = retryData(event.data) - if (data === undefined) return null - return { id: String(data.retryId), role: data.retry === 1 ? 'start' : 'update' } + if (event.type === 'llm/retry') { + return { id: String(event.data.retryId), role: event.data.retry === 1 ? 'start' : 'update' } } - if ((event.type as string) === 'llm/retry-started') { - const data = event.data as unknown as { retryId?: unknown } - return typeof data.retryId === 'string' ? { id: data.retryId, role: 'update' } : null + if (event.type === 'llm/retry-started') { + return { id: String(event.data.retryId), role: 'update' } } return null }, start: (_context, match) => { - const node = scheduledNode(match.event) + const node = scheduledNode(match) if (node === undefined) throw new Error('model-retry start requires a valid llm/retry event') return { turn: node.turn, step: node.step, attempts: [node] } }, update: (context, match) => { - if ((match.event.type as string) === 'llm/retry') { - const node = scheduledNode(match.event) + if (match.event.type === 'llm/retry') { + const node = scheduledNode(match) return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] } } - if ((match.event.type as string) !== 'llm/retry-started') return context.state - const data = match.event.data as unknown as { retry: number } + if (match.event.type !== 'llm/retry-started') return context.state + const retry = match.event.data.retry return { ...context.state, attempts: context.state.attempts.map(attempt => - attempt.retry === data.retry ? { ...attempt, retryState: 'started' } : attempt), + attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt), } }, buildViewNode: (context, target) => { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts index 23201fec3e..dc9e996dcc 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -4,6 +4,7 @@ import type { RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-tools/types' import type { ToolChatData } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' @@ -141,27 +142,30 @@ function acceptsEdge(state: ToolState, parent: string, child: string): boolean { } function updateDispatch(state: ToolState, match: ConversationMatch): ToolState { - const data = match.event.data as unknown as DispatchData - const siblings = state.children.get(data.parentCallId) ?? [] - const index = siblings.findIndex(candidate => candidate.callId === data.subCallId) - if ((match.event.type as string) === 'tool/code-dispatch-start') { - if (index >= 0 || !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + const event = match.event + if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state + const data = event.data + const parentCallId = String(data.parentCallId) + const subCallId = String(data.subCallId) + const siblings = state.children.get(parentCallId) ?? [] + const index = siblings.findIndex(candidate => candidate.callId === subCallId) + if (event.type === 'tool/code-dispatch-start') { + if (index >= 0 || !acceptsEdge(state, parentCallId, subCallId)) return state const children = new Map(state.children) - children.set(data.parentCallId, [...siblings, childCall(match, data)]) + children.set(parentCallId, [...siblings, childCall(match, data)]) const parents = new Map(state.parents) - parents.set(data.subCallId, data.parentCallId) + parents.set(subCallId, parentCallId) return { ...state, children, parents } } - if ((match.event.type as string) !== 'tool/code-dispatch') return state - if (index < 0 && !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + if (index < 0 && !acceptsEdge(state, parentCallId, subCallId)) return state const previous = index < 0 ? undefined : siblings[index] const settled = childResult(match, data, previous) const children = new Map(state.children) - children.set(data.parentCallId, index < 0 + children.set(parentCallId, index < 0 ? [...siblings, settled] : siblings.map((child, at) => at === index ? settled : child)) const parents = new Map(state.parents) - if (index < 0) parents.set(data.subCallId, data.parentCallId) + if (index < 0) parents.set(subCallId, parentCallId) return { ...state, children, parents } } @@ -236,9 +240,8 @@ export const toolDefinition: ConversationNodeDefinition = { if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { return { id: String(event.data.message.source.callId), role: 'update' } } - if ((event.type as string) === 'tool/code-dispatch-start' || (event.type as string) === 'tool/code-dispatch') { - const data = event.data as unknown as { rootCallId: string } - return { id: data.rootCallId, role: 'update' } + if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { + return { id: String(event.data.rootCallId), role: 'update' } } return null }, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts index 58725d8723..60b2fca087 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts @@ -3,6 +3,7 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-runtime/client' import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' import { chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { @@ -30,9 +31,9 @@ function lastStep(context: ConversationNodeContext): number { } function retryTurn(event: Parameters[0]): number | undefined { - if ((event.type as string) !== 'llm/retry' && (event.type as string) !== 'llm/retry-started') return undefined - const turn = (event.data as unknown as { turn?: unknown }).turn - return Number.isSafeInteger(turn) && (turn as number) >= 0 ? turn as number : undefined + return event.type === 'llm/retry' || event.type === 'llm/retry-started' + ? event.data.turn + : undefined } function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts index 51cb348e6a..2a1b4d2b13 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -3,6 +3,7 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { AssistantChatData, FinalAssistantChatData, TurnTailChatData, } from '../contract/chat-nodes.ts' @@ -58,9 +59,7 @@ function turnCoordinates(event: Parameters[ || event.type === 'step/end') { return { turn: event.data.turn, step: event.data.step } } - if ((event.type as string) === 'llm/retry') { - return event.data as unknown as { turn: number; step: number } - } + if (event.type === 'llm/retry') return { turn: event.data.turn, step: event.data.step } return undefined } @@ -90,7 +89,7 @@ function closingAnchor(context: ConversationNodeContext): number } continue } - if ((event.type as string) === 'llm/retry') { + if (event.type === 'llm/retry') { steps.set(coordinates.step, { streamedText: false, finalized: false }) continue } @@ -130,7 +129,7 @@ function tailData(context: ConversationNodeContext): TurnTailChat const candidate = event.type === 'tool/call' || (event.type === 'tool/result' && isAppendSurfaceEvent(event)) || (event.type === 'turn/end' && event.data.reason.kind === 'error') - || (event.type as string) === 'llm/retry' + || event.type === 'llm/retry' ? event.seq : undefined if (candidate !== undefined && (latestTranscriptSeq === undefined || candidate > latestTranscriptSeq)) { diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 3481ee758e..41ffd329b8 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -62,7 +62,9 @@ describe('apply wiring', () => { expect(entries[0]?.options.order).toBe(0) // Declaring is claiming: the chat entry's registration put the hole on // the ledger with the contract's kind/scope. - expect(b.slots.spec('conversation.chat.node')).toEqual({ kind: 'keyed', scope: 'session' }) + const nodeSlot = b.slots.spec('conversation.chat.node') + expect(nodeSlot).toMatchObject({ kind: 'keyed', scope: 'session' }) + expect(nodeSlot?.inject?.hooks?.turnData).toBeTypeOf('function') await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index 5346ad57f9..3e5dcccff3 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -644,6 +644,28 @@ describe('built-in conversation node Definitions', () => { }) }) + it('renders a historical compaction when its start remains outside the loaded window', () => { + const value = assembler([ + at(10, 'compact/summary', { + compactionId: 'compact-windowed', + summary: [{ type: 'text', text: 'loaded summary' }], + shadowedSeqs: [1, 2, 3], + shadowedTokenCount: 42, + }), + at(11, 'user/message', { + ...textMessage('checkpoint-windowed', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }), + ], true) + + expect(node(snapshot(value), 'compaction')?.data).toMatchObject({ + summary: 'loaded summary', + summaryEventSeq: 10, + shadowedItemCount: 3, + shadowedTokenCount: 42, + }) + }) + it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => { const value = assembler([ at(5, 'llm/retry', { diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 89d0067caf..0d6f250441 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -23,12 +23,24 @@ { "path": "../runtime" }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../interaction/commands" + }, { "path": "../../session/session-projection" }, { "path": "../../llm/token-meter" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../plan/plan-mode" }, diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index bb4150ee32..860145e542 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -15,14 +15,14 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./checkpoint": { "types": "./lib/types/checkpoint.d.ts", "default": "./lib/types/checkpoint.js" }, - "./brand": { - "types": "./lib/types/brand.d.ts", - "default": "./lib/types/brand.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index df5e749cd8..53c3baf05f 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -9,7 +9,9 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { CompactionId } from './brand.ts' +import { CompactionId } from './brand.ts' + +export { CompactionId } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 9f64d33e75..d186f32d03 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -16,8 +16,8 @@ "default": "./lib/invariant.js" }, "./types": { - "types": "./lib/types/types.d.ts", - "default": "./lib/types/types.js" + "types": "./lib/types/session-types.d.ts", + "default": "./lib/types/session-types.js" }, "./src/*": "./src/*", "./package.json": "./package.json" diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index cf07f24ecf..7eff09b01d 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -10,7 +10,7 @@ import type { Context, Events } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' -import type { Agent } from './types.ts' +import type { Agent } from './runtime-types.ts' /** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */ type Params = F extends (...args: infer P) => unknown ? P : never diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index d457277035..2db3c4b580 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -6,9 +6,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm' import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' - -/** One of the two ordered pending-message lists owned by an agent. */ -export type InboxTarget = 'next-turn' | 'next-step' +import type { InboxTarget } from './session-types.ts' /** Mutable state privately owned by an {@link Inbox}. */ type InboxState = Record diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index bedfc819dc..e35bc06211 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -13,9 +13,10 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' -import type { Agent, AgentOptions } from './types.ts' +import type { Agent, AgentOptions } from './runtime-types.ts' -export * from './types.ts' +export * from './runtime-types.ts' +export * from './session-types.ts' export * from './inbox.ts' export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/runtime-types.ts similarity index 96% rename from packages/core/agent/src/types.ts rename to packages/core/agent/src/runtime-types.ts index 8decb9ba3f..072f509f8f 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -2,7 +2,7 @@ * Public agent types and live-runtime events. Durable transcript facts and * turn/step boundaries remain `@deepseek-ai/dsh-session` events. * - * @module @deepseek-ai/dsh-agent/types + * @module @deepseek-ai/dsh-agent */ import type { Context } from 'cordis' @@ -10,7 +10,8 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' export type { AgentCancelCause } from '@deepseek-ai/dsh-session' -import type { Inbox, InboxTarget } from './inbox.ts' +import type { Inbox } from './inbox.ts' +import type { InboxTarget } from './session-types.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -289,20 +290,3 @@ declare module 'cordis' { 'agent/error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void } } - -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. - */ - 'agent/inbox/spliced': { - target: InboxTarget - start: number - removedCount?: number - inserted: UserMessage[] - outcome?: 'canceled' - } - } -} diff --git a/packages/core/agent/src/session-types.ts b/packages/core/agent/src/session-types.ts new file mode 100644 index 0000000000..b54e56ea8f --- /dev/null +++ b/packages/core/agent/src/session-types.ts @@ -0,0 +1,27 @@ +/** + * Durable agent session-event vocabulary shared with type-only consumers. + * + * @module @deepseek-ai/dsh-agent/types + */ + +import type { UserMessage } from '@deepseek-ai/dsh-llm/types' + +/** One of the two ordered pending-message lists owned by an agent. */ +export type InboxTarget = 'next-turn' | 'next-step' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * One normalized mutation of an agent's durable pending-message lists. + * Live dispatch precedes projection mutation, so synchronous observers may + * read the pre-splice inbox to recover the removed messages. + */ + 'agent/inbox/spliced': { + target: InboxTarget + start: number + removedCount?: number + inserted: UserMessage[] + outcome?: 'canceled' + } + } +} diff --git a/packages/core/session/tests/gen-persistence-catalog.spec.ts b/packages/core/session/tests/gen-persistence-catalog.spec.ts index 22bc09835f..4fee74793f 100644 --- a/packages/core/session/tests/gen-persistence-catalog.spec.ts +++ b/packages/core/session/tests/gen-persistence-catalog.spec.ts @@ -89,7 +89,7 @@ describe('gen-persistence-catalog collectLogEvents', () => { it('hard-errors on an extends clause (inherited keys would escape the catalog)', () => { expect(() => collectLogEvents(make({ 'packages/group/fix/src/types.ts': - 'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n', + 'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session/types\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n', }))).toThrow(/uses extends; inherited keys would join keyof SessionEventMap without a catalog row/) }) diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 3279f17729..0fcc66b497 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -15,6 +15,10 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./presentation": { "types": "./lib/types/presentation.d.ts", "default": "./lib/types/presentation.js" diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 984fecab88..f5c5809544 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -14,41 +14,7 @@ import type { JsonValue } from '@deepseek-ai/dsh-session' import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts' import { TOOL_REGISTRY_SCHEDULER } from './index.ts' import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts' - -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * One sub-dispatch STARTING inside a `run_code` program: the parent - * `run_code` call id, the deterministic sub-call id (`:code:`, - * numbered in submission order), and the tool `name` with its - * JSON-normalized `arguments` — the exact value dispatched, normalized - * BEFORE dispatch, so this append can never fail on payload shape. - * Appended when the scheduler actually starts the call (not at - * submission), so a start means the tool body pipeline was entered; a - * call abandoned in the queue logs nothing. Log-only: `deriveMessages()` - * ignores it; UIs use it for live per-sub-call running state and pair it - * with `tool/code-dispatch` by `subCallId` (timing = the two events' - * `time` fields). - */ - 'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } - /** - * One bridged sub-dispatch SETTLING: the pairing ids (matching the - * `tool/code-dispatch-start` with the same `subCallId`), the tool `name` - * with the same JSON-normalized `arguments`, and the sub-call's complete - * model-facing outcome in `tool/result`'s own vocabulary - * (`content` + `isError`), so UIs render a sub-call through the exact - * code path that renders a native call. Every started sub-call settles - * with exactly one of these (abort included: the aborted pipeline result - * is an `isError` outcome). - * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter - * model context; persistence and UIs get every call. Appended inside the - * parent `run_code`'s execution (the bridge drains in-flight dispatches - * before returning), so its execution-enclosure relation holds by - * construction. - */ - 'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } - } -} +import type {} from './types.ts' /** The model-facing name of the Code Mode tool. */ export const RUN_CODE_NAME = 'run_code' diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 6338e54501..1a5f6a05eb 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -85,6 +85,7 @@ export { } from './json-schema.ts' export type { JsonValue } from '@deepseek-ai/dsh-session' +export type { CodeDispatchEventData, CodeDispatchStartEventData } from './types.ts' export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' diff --git a/packages/core/tools/src/types.ts b/packages/core/tools/src/types.ts new file mode 100644 index 0000000000..4dd6f7a9a8 --- /dev/null +++ b/packages/core/tools/src/types.ts @@ -0,0 +1,58 @@ +/** + * Durable Tool event vocabulary shared with type-only consumers. + * + * @module @deepseek-ai/dsh-tools/types + */ + +import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' + +/** Payload recorded when one nested Code Mode Tool dispatch starts. */ +export interface CodeDispatchStartEventData { + rootCallId: CallId + parentCallId: CallId + subCallId: CallId + name: string + arguments: unknown +} + +/** Payload recorded when one nested Code Mode Tool dispatch settles. */ +export interface CodeDispatchEventData extends CodeDispatchStartEventData { + isError: boolean + content: ContentBlock[] +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * One sub-dispatch STARTING inside a `run_code` program: the parent + * `run_code` call id, the deterministic sub-call id (`:code:`, + * numbered in submission order), and the tool `name` with its + * JSON-normalized `arguments` — the exact value dispatched, normalized + * BEFORE dispatch, so this append can never fail on payload shape. + * Appended when the scheduler actually starts the call (not at + * submission), so a start means the tool body pipeline was entered; a + * call abandoned in the queue logs nothing. Log-only: `deriveMessages()` + * ignores it; UIs use it for live per-sub-call running state and pair it + * with `tool/code-dispatch` by `subCallId` (timing = the two events' + * `time` fields). + */ + 'tool/code-dispatch-start': CodeDispatchStartEventData + /** + * One bridged sub-dispatch SETTLING: the pairing ids (matching the + * `tool/code-dispatch-start` with the same `subCallId`), the tool `name` + * with the same JSON-normalized `arguments`, and the sub-call's complete + * model-facing outcome in `tool/result`'s own vocabulary + * (`content` + `isError`), so UIs render a sub-call through the exact + * code path that renders a native call. Every started sub-call settles + * with exactly one of these (abort included: the aborted pipeline result + * is an `isError` outcome). + * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter + * model context; persistence and UIs get every call. Appended inside the + * parent `run_code`'s execution (the bridge drains in-flight dispatches + * before returning), so its execution-enclosure relation holds by + * construction. + */ + 'tool/code-dispatch': CodeDispatchEventData + } +} diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 7d35603a79..cd62942a5f 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -15,6 +15,10 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./brand": { "types": "./lib/types/brand.d.ts", "default": "./lib/types/brand.js" diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index ba7b449228..9f4110568e 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -11,24 +11,12 @@ import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-se import { CommandId } from './brand.ts' export { CommandId } from './brand.ts' +export type { CommandSource, CommandSourceMap } from './types.ts' export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -/** - * Producer record for one command invocation (the `command/run` event's - * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s - * shape; minimal today because every executor caller is a human-facing UI - * surface dispatching a human-typed line, so the sole variant is `user`. - */ -export interface CommandSourceMap { - user: { kind: 'user' } -} - -/** The union over {@link CommandSourceMap} — who issued a command line. */ -export type CommandSource = CommandSourceMap[keyof CommandSourceMap] - /** Immutable metadata for a command's optional unstructured input. */ export interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ @@ -131,34 +119,6 @@ class CommandLayer implements ScopeLayer { } } -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * A resolved slash command entered its handler. Log-only (never model - * surface); paired with `command/done` by `commandId`, mirroring the - * `tool/call`↔`tool/result` pairing. The payload is structured — `name` - * and `args` are `parseCommand`'s own split (name and verbatim rawInput, - * separator whitespace included), so a consumer (a projection unit - * folding its own command records, a rich command card) never re-parses - * a line. `args` is absent when the definition sets `recordInput: false` - * because an authoritative domain event owns the input payload. - */ - 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } - /** - * The paired command settled. `kind`/`text` carry the handler's verbatim - * outcome (a thrown/aborted handler settles as `kind: 'error'` with the - * rendered failure). A successful command may identify the earlier - * authoritative domain event for a richer client-computed presentation. - */ - 'command/done': { - commandId: CommandId - kind: 'success' | 'error' - text?: string - sourceEventSeq?: number - } - } -} - declare module 'cordis' { interface Context { commands: CommandService diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts new file mode 100644 index 0000000000..2b81071d37 --- /dev/null +++ b/packages/interaction/commands/src/types.ts @@ -0,0 +1,48 @@ +/** + * Durable command event vocabulary shared with type-only consumers. + * + * @module @deepseek-ai/dsh-commands/types + */ + +import type { CommandId } from './brand.ts' + +/** + * Producer record for one command invocation (the `command/run` event's + * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s + * shape; minimal today because every executor caller is a human-facing UI + * surface dispatching a human-typed line, so the sole variant is `user`. + */ +export interface CommandSourceMap { + user: { kind: 'user' } +} + +/** The union over {@link CommandSourceMap} — who issued a command line. */ +export type CommandSource = CommandSourceMap[keyof CommandSourceMap] + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * A resolved slash command entered its handler. Log-only (never model + * surface); paired with `command/done` by `commandId`, mirroring the + * `tool/call`↔`tool/result` pairing. The payload is structured — `name` + * and `args` are `parseCommand`'s own split (name and verbatim rawInput, + * separator whitespace included), so a consumer (a projection unit + * folding its own command records, a rich command card) never re-parses + * a line. `args` is absent when the definition sets `recordInput: false` + * because an authoritative domain event owns the input payload. + */ + 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } + /** + * The paired command settled. `kind`/`text` carry the handler's verbatim + * outcome (a thrown/aborted handler settles as `kind: 'error'` with the + * rendered failure). A successful command may identify the earlier + * authoritative domain event for a richer client-computed presentation. + */ + 'command/done': { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number + } + } +} diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 1685d0e552..809b5d14e5 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -19,10 +19,6 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, - "./brand": { - "types": "./lib/types/brand.d.ts", - "default": "./lib/types/brand.js" - }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts index c698b68446..5144939289 100644 --- a/packages/llm/llm-retry/src/types.ts +++ b/packages/llm/llm-retry/src/types.ts @@ -1,5 +1,7 @@ import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' -import type { RetryId } from './brand.ts' +import { RetryId } from './brand.ts' + +export { RetryId } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 33ac3a19fa..917a6d03f8 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -5,7 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' -import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/types' import { providerForOpenStep } from '../src/history.ts' async function setup(): Promise { diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 3a605bd40e..9e17290b43 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' -import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/types' import type {} from '../src/index.ts' const dirs: string[] = [] diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 9251eef2c0..bfbf6a89ee 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -10,7 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' -import { CompactionId } from '@deepseek-ai/dsh-compact/brand' +import { CompactionId } from '@deepseek-ai/dsh-compact/types' import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts' import { estimateContent, diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 65771fc877..3f66e0564b 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -7,7 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' -import { CompactionId } from '@deepseek-ai/dsh-compact/brand' +import { CompactionId } from '@deepseek-ai/dsh-compact/types' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 0276199e3e..db60b695b1 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { CompactionId } from '@deepseek-ai/dsh-compact/brand' +import { CompactionId } from '@deepseek-ai/dsh-compact/types' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1ee272761..6fb20b9718 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1495,6 +1495,9 @@ importers: packages/client/runtime: dependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -1522,6 +1525,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools immer: specifier: ^10.1.1 version: 10.2.0 @@ -1656,6 +1662,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -1677,6 +1686,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../../compact/compact @@ -1686,6 +1698,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-permission': specifier: workspace:^ version: link:../../interaction/permission @@ -1701,6 +1716,9 @@ importers: '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools '@types/react': specifier: ~18.3.1 version: 18.3.31 diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 5294242e27..173d4222cb 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -18,8 +18,11 @@ const OUT = 'docs/persistence-catalog.md' * doc-typecheck, since their imported types are not standalone-compilable). */ const FENCE = 'ts persistence-catalog' -/** The package whose module id plugin merges augment (`declare module '…'`). */ -const SESSION_MODULE = '@deepseek-ai/dsh-session' +/** The package that owns the durable event vocabulary. */ +const SESSION_PACKAGE = '@deepseek-ai/dsh-session' + +/** The type-only module that plugin declaration merges augment. */ +const SESSION_TYPES_MODULE = '@deepseek-ai/dsh-session/types' /** Event-envelope declarations rendered before the per-event vocabulary. */ const EVENT_ENVELOPE_TYPE_NAMES = [ @@ -115,7 +118,7 @@ function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string /** * Every `interface SessionEventMap` declaration in a source file: the owning * top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration - * merge inside a `declare module '@deepseek-ai/dsh-session'` block. Both forms + * merge inside a `declare module '@deepseek-ai/dsh-session/types'` block. Both forms * declare members of the SAME merged interface, so both are catalogued * uniformly. `topLevel` distinguishes the owning form so the caller can verify * it actually lives in the owning package — an unrelated local interface that @@ -125,7 +128,7 @@ function sessionEventMapDecls(sf: ts.SourceFile): { decl: ts.InterfaceDeclaratio const decls: { decl: ts.InterfaceDeclaration; topLevel: boolean }[] = [] for (const stmt of sf.statements) { if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'SessionEventMap') decls.push({ decl: stmt, topLevel: true }) - if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_MODULE + if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_TYPES_MODULE && stmt.body && ts.isModuleBlock(stmt.body)) { for (const inner of stmt.body.statements) { if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'SessionEventMap') decls.push({ decl: inner, topLevel: false }) @@ -174,8 +177,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { // the owning package. Same-named interfaces elsewhere are different // types and must not enter the on-disk catalog. const pkg = packageNameFor(rel, scanRoot) - if (pkg !== SESSION_MODULE) { - violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`) + if (pkg !== SESSION_PACKAGE) { + violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_PACKAGE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_TYPES_MODULE}'.`) continue } const exported = decl.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false @@ -243,7 +246,7 @@ export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelop const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue - if (packageNameFor(rel, scanRoot) !== SESSION_MODULE) continue + if (packageNameFor(rel, scanRoot) !== SESSION_PACKAGE) continue const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue @@ -352,7 +355,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv '', '# Session Persistence Event Catalog', '', - 'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).', + 'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge into `@deepseek-ai/dsh-session/types` in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).', '', '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).', '', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index cd9d020779..73431101da 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -114,12 +114,12 @@ { "doc": "docs/subsystems/core.md", "symbol": "InboxTarget", - "source": "packages/core/agent/src/inbox.ts" + "source": "packages/core/agent/src/session-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "CancelOptions", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", @@ -129,22 +129,22 @@ { "doc": "docs/subsystems/core.md", "symbol": "Agent", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "PreStepDecision", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "RequestErrorAction", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "SessionStartSource", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/scope.md", @@ -1698,12 +1698,12 @@ { "doc": "docs/subsystems/core.md", "symbol": "AgentStatus", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "AgentOptions", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" } ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index d8b777a2ad..c7be2d4925 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -71,8 +71,11 @@ "@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], + "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"], + "@deepseek-ai/dsh-compact/types": ["./packages/compact/compact/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], + "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/interaction/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/interaction/user-interaction/src/types.ts"], From 1b1e170c1f291b53af8fa5ac1257530a7e59f8fd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:43:37 +0800 Subject: [PATCH 13/32] refactor(agent): restore conventional types entrypoint --- packages/core/agent/package.json | 4 ++-- packages/core/agent/src/inbox.ts | 2 +- packages/core/agent/src/index.ts | 2 +- packages/core/agent/src/runtime-types.ts | 2 +- packages/core/agent/src/{session-types.ts => types.ts} | 0 scripts/type-equiv.manifest.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename packages/core/agent/src/{session-types.ts => types.ts} (100%) diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index d186f32d03..9f64d33e75 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -16,8 +16,8 @@ "default": "./lib/invariant.js" }, "./types": { - "types": "./lib/types/session-types.d.ts", - "default": "./lib/types/session-types.js" + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" }, "./src/*": "./src/*", "./package.json": "./package.json" diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index 2db3c4b580..c6b9204c92 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -6,7 +6,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm' import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' -import type { InboxTarget } from './session-types.ts' +import type { InboxTarget } from './types.ts' /** Mutable state privately owned by an {@link Inbox}. */ type InboxState = Record diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index e35bc06211..4df0056c1a 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -16,7 +16,7 @@ import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' import type { Agent, AgentOptions } from './runtime-types.ts' export * from './runtime-types.ts' -export * from './session-types.ts' +export * from './types.ts' export * from './inbox.ts' export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 072f509f8f..3698c05018 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -11,7 +11,7 @@ import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-a import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' export type { AgentCancelCause } from '@deepseek-ai/dsh-session' import type { Inbox } from './inbox.ts' -import type { InboxTarget } from './session-types.ts' +import type { InboxTarget } from './types.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { diff --git a/packages/core/agent/src/session-types.ts b/packages/core/agent/src/types.ts similarity index 100% rename from packages/core/agent/src/session-types.ts rename to packages/core/agent/src/types.ts diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 73431101da..fccabef852 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -114,7 +114,7 @@ { "doc": "docs/subsystems/core.md", "symbol": "InboxTarget", - "source": "packages/core/agent/src/session-types.ts" + "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/subsystems/core.md", From 924cfcef9595e101ec090d1f118c8f557e11469d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:56:29 +0800 Subject: [PATCH 14/32] fix: coverage --- .../src/client/contract/conversation.ts | 4 ++++ .../client/sessions/conversation-assembler.ts | 5 ++--- .../sessions/conversation-location-index.ts | 2 +- .../client/test-runtime/tests/runtime.spec.tsx | 3 ++- .../src/client/contract/chat-nodes.ts | 2 +- .../src/client/contract/slots.ts | 2 +- .../tests/chat-snapshot-fixture.ts | 7 +++++-- .../ui-conversation/tests/chat-view.spec.tsx | 18 ++++++++++-------- .../tests/produced-files.spec.tsx | 7 +++++-- .../client/ui-settings/tests/apply.spec.ts | 12 ++++++------ packages/client/ui-slots/src/index.ts | 10 ++++++++-- packages/typert/generator/src/analyzer.ts | 2 +- 12 files changed, 46 insertions(+), 28 deletions(-) diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index fb98965d25..9507046b33 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -1,6 +1,10 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client' +/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents -- + * The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program; + * installed business packages supply their concrete keys in consuming Client programs. */ + /** One raw log event plus its optional envelope-level presentation view. */ export interface ConversationEventInput { readonly event: SessionEvent diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index 811bd43ebf..bdd89f56a4 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -712,9 +712,8 @@ export class ConversationNodeAssembler { context: InternalContext, scope: ConversationLocationDataScope, ): ConversationLocationData | null { - const build = context.definition.buildLocationData - if (build === undefined) return null - const data = build(contextSnapshot(context), scope) + if (context.definition.buildLocationData === undefined) return null + const data = context.definition.buildLocationData(contextSnapshot(context), scope) if (data === null) return null if (data.kind !== scope) { throw new Error( diff --git a/packages/client/runtime/src/client/sessions/conversation-location-index.ts b/packages/client/runtime/src/client/sessions/conversation-location-index.ts index c842a230a9..f4ce189b13 100644 --- a/packages/client/runtime/src/client/sessions/conversation-location-index.ts +++ b/packages/client/runtime/src/client/sessions/conversation-location-index.ts @@ -20,7 +20,7 @@ export interface ConversationLocationDataChange { class MutableLocationDataStore { private entries = new Map() - get(key: Key): unknown { + get(key: string): unknown { return this.entries.get(key)?.value } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index f92d21b4b5..08a82ee251 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -18,6 +18,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } } 'trt.chat': { kind: 'single'; scope: 'session' } 'trt.rows': { kind: 'list'; scope: 'root' } + 'trt.rows.hole': { kind: 'single'; scope: 'root' } } } @@ -419,7 +420,7 @@ describe('feature mount and disposal', () => { await feature.dispose() await feature.dispose() // idempotent expect(runtime.slots.entries('trt.rows')).toHaveLength(0) - expect(runtime.slots.spec('trt.rows.hole' as never)).toBeUndefined() + expect(runtime.slots.spec('trt.rows.hole')).toBeUndefined() expect(runtime.ctx.get('feature-service')).toBeUndefined() expect(view.queryByTestId('row')).toBeNull() await runtime.dispose() diff --git a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts index 3415c502a5..787f391006 100644 --- a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts @@ -7,7 +7,7 @@ import type { export interface ChatNodeDataMap {} /** Renderer kinds contributed by the currently installed Chat business modules. */ -export type ChatNodeKind = keyof ChatNodeDataMap & string +export type ChatNodeKind = Extract /** Final Chat Node narrowed to one registered renderer kind and payload. */ export type ChatNode = { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index eec5450e2d..56fca2381c 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -210,7 +210,7 @@ export interface TurnTailOwnerProps { } /** Hook constrained to business data published on the current Chat Node's Turn. */ -export type UseChatNodeTurnData = ( +export type UseChatNodeTurnData = >( key: Key, ) => Readonly | undefined diff --git a/packages/client/ui-conversation/tests/chat-snapshot-fixture.ts b/packages/client/ui-conversation/tests/chat-snapshot-fixture.ts index c675554ea0..fb343f0f68 100644 --- a/packages/client/ui-conversation/tests/chat-snapshot-fixture.ts +++ b/packages/client/ui-conversation/tests/chat-snapshot-fixture.ts @@ -78,13 +78,16 @@ class FixtureLocationIndex implements ChatLocationNodeIndex { class FixtureTurnDataStore implements ConversationLocationDataStore { private readonly values = new Map() - get( + get>( key: Key, ): Readonly | undefined { return this.values.get(key) as Readonly | undefined } - set(key: Key, value: ConversationTurnDataMap[Key]): void { + set>( + key: Key, + value: ConversationTurnDataMap[Key], + ): void { this.values.set(key, value) } } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 296dd85966..ca6223826e 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -181,12 +181,14 @@ function makeHarness(init?: Partial) { if (key !== 'conversation.chat.node') return opts?.fallback ?? null const nodeOwner = owner as RoutedChatNodeOwner const nodeKey = opts?.hookContext as string | undefined - const useTurnData = ((dataKey: string) => props.useSession((snapshot) => { - const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location - return location?.kind === 'turn' || location?.kind === 'step' - ? location.turn.data.get(dataKey as never) - : undefined - })) as UseChatNodeTurnData + const useTurnData = ((dataKey: string) => { + return props.useSession((snapshot) => { + const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(dataKey as never) + : undefined + }) + }) as UseChatNodeTurnData const nodeProps = (): ChatNodeViewProps => ( { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps ) @@ -226,7 +228,7 @@ function makeHarness(init?: Partial) { case 'unknown': return ()} /> case 'tool-call': { - const block = (nodeOwner.node.data as { readonly root: ToolCallBlock }).root + const block = nodeOwner.node.data.root const toolName = 'kind' in block ? block.call?.name ?? '' : block.name const tool = { callId: block.callId, @@ -843,7 +845,7 @@ describe('ChatView', () => { mounted() return () => { unmounted() } }, []) - const root = (node.data as { readonly root: ToolCallBlock }).root + const root = node.data.root return (
{root.callId} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index b7ca73a79f..b8e7cd111e 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -34,13 +34,16 @@ afterEach(cleanup) class TestTurnDataStore implements ConversationLocationDataStore { private readonly values = new Map() - get( + get>( key: Key, ): Readonly | undefined { return this.values.get(key) as Readonly | undefined } - set(key: Key, value: ConversationTurnDataMap[Key]): void { + set>( + key: Key, + value: ConversationTurnDataMap[Key], + ): void { this.values.set(key, value) } } diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index c46a5bf25c..3133ad4d76 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -44,8 +44,8 @@ describe('ui-settings apply', () => { declare(before.slots) await before.ctx.plugin({ inject: [...inject], apply }).await() expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const [name, spec] of Object.entries(CHILD_SPECS)) { - expect(before.slots.spec(name as never)).toEqual(spec) + for (const name of Object.keys(CHILD_SPECS) as Array) { + expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name]) } const after = await bench() @@ -120,8 +120,8 @@ describe('ui-settings apply', () => { declare(b.slots) await Promise.resolve() expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const [name, spec] of Object.entries(CHILD_SPECS)) { - expect(b.slots.spec(name as never)).toEqual(spec) + for (const name of Object.keys(CHILD_SPECS) as Array) { + expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name]) } }) @@ -132,8 +132,8 @@ describe('ui-settings apply', () => { await fiber.await() await fiber.dispose() expect(b.slots.entries('sidebar.settings')).toHaveLength(0) - for (const name of Object.keys(CHILD_SPECS)) { - expect(b.slots.spec(name as never)).toBeUndefined() + for (const name of Object.keys(CHILD_SPECS) as Array) { + expect(b.slots.spec(name)).toBeUndefined() } }) }) diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 4ed6ecf519..d6c1824c99 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -677,7 +677,10 @@ export class SlotCore { >( options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, + HandleOf>, object, NoInfer, NoInfer + >> & RendersCheck, ): () => void /** @@ -702,7 +705,10 @@ export class SlotCore { >( options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, + HandleOf>, I, NoInfer, NoInfer + >> & RendersCheck, ): () => void /* jscpd:ignore-end */ diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 6f495aa318..68736f0b3a 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -274,7 +274,7 @@ export class WorkspaceAnalyzer { constructor(options: WorkspaceAnalyzerOptions) { this.options = { - root: resolve(options.root), + root: realPath(options.root), hostConfig: options.hostConfig ?? 'tsconfig.host.json', clientConfig: options.clientConfig ?? 'tsconfig.client.json', faces: options.faces ?? ['host', 'client'], From 27682b938493cfe9e4580a568f006d561d1302de Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:56:34 +0800 Subject: [PATCH 15/32] fix: docs --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 4 +- docs/event-producer-consumer.i18n.yaml | 2 +- docs/event-producer-consumer.md | 38 +++++++++---------- docs/module-graph.i18n.yaml | 2 +- docs/module-graph.md | 6 ++- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.md | 32 +++++++--------- docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 4 +- docs/subsystems/commands.zh.md | 4 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 26 ++++++------- docs/subsystems/core.zh.md | 26 ++++++------- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 14 +++---- docs/subsystems/tools.zh.md | 14 +++---- 18 files changed, 96 insertions(+), 96 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 264b900b74..d19cd86c7d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: f9768a652c7b0d2d29939fac83b201bc904e3210 -2026-08-09-client-conversation-node-assembly.zh.md: 408d61f73b3b317214cb10b26f522e7680de3f92 +2026-08-09-client-conversation-node-assembly.md: 9952f8fac0f2a3b0fe72cc97ae9a8fb4f96ace08 +2026-08-09-client-conversation-node-assembly.zh.md: f27a82459cdacdb4a2da27cf12b30c93f2dfdf64 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9607dc8d1a..12f3e2861c 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: 5f3bc744b25bf20d50e88ce75812fe8bd624905a +config-catalog.md: 7278447cf85c2b25f7dd42e10e4b0b1cbd7eb96b config-catalog.zh.md: a7d4252c526d36643a1b9f7aebd627faa60e1c77 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 22e21edc0b..7278447cf8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -917,7 +917,7 @@ Requires: `agents` export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:33`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:24`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` @@ -2325,7 +2325,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:623`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:624`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 41da1ee118..812b490bef 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: 5b36725402c0a12c5e2a09c743c2e7cf28d2c14a +event-producer-consumer.md: 05ff62a391a0acb7db9abd2c9ce0c2082da52eaa event-producer-consumer.zh.md: 2d4c805f9a5d3b0531ee59eebe9e6564347f0a00 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a8263b70c1..05ff62a391 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,20 +8,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:174`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:134`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../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:35`](../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`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:137`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d6f09d6696..24ddaad77c 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: e7d6b47e709c3edf4dcdb506bd8f2be0c717aaab +module-graph.md: a248ed4fcb8abc17ffc6982d2733b3c3c2a2a635 module-graph.zh.md: 255253bcfa9cfad1ab85602dfa9f953a02dfc427 diff --git a/docs/module-graph.md b/docs/module-graph.md index 767ebfcfde..a248ed4fcb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1000,14 +1000,18 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt + pkg_client_ui_conversation --> pkg_agent pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives pkg_client_ui_conversation --> pkg_client_ui_slash pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_commands pkg_client_ui_conversation --> pkg_compact pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm_retry pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tools pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1354,7 +1358,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 19c1f1b280..d814b6ac6e 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: 614f6afbeb8fbaadbb43a76782c5a345180b25e7 +persistence-catalog.md: c9aa15cd827d99cee64e7a33db11995ee17f9bf8 persistence-catalog.zh.md: 364912b88c3b2c5efd92a616034be9ef5025ee67 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 76487ca3d4..c9aa15cd82 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -3,7 +3,7 @@ # Session Persistence Event Catalog -Every event type that can appear in a session's durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit). +Every event type that can appear in a session's durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge into `@deepseek-ai/dsh-session/types` in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit). 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). @@ -101,7 +101,7 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) ### `approval/*` @@ -212,7 +212,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/index.ts:153`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/types.ts:41`](../packages/interaction/commands/src/types.ts) #### `command/run` — log-only @@ -230,7 +230,7 @@ Source: [`packages/interaction/commands/src/index.ts:153`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/index.ts:146`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/types.ts:34`](../packages/interaction/commands/src/types.ts) ### `compact/*` @@ -244,7 +244,7 @@ Source: [`packages/interaction/commands/src/index.ts:146`](../packages/interacti 'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:69`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:71`](../packages/compact/compact/src/types.ts) #### `compact/prune` — log-only @@ -268,7 +268,7 @@ Source: [`packages/compact/compact/src/types.ts:69`](../packages/compact/compact } ``` -Source: [`packages/compact/compact/src/types.ts:79`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:81`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -281,7 +281,7 @@ Source: [`packages/compact/compact/src/types.ts:79`](../packages/compact/compact 'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } ``` -Source: [`packages/compact/compact/src/types.ts:21`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:23`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -333,7 +333,7 @@ Source: [`packages/compact/compact/src/types.ts:21`](../packages/compact/compact Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/compact/compact/src/types.ts:31`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact/src/types.ts) ### `feedback/*` @@ -417,7 +417,7 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- 'llm/retry': LlmRetryEventData ``` -Source: [`packages/llm/llm-retry/src/index.ts:20`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/types.ts:9`](../packages/llm/llm-retry/src/types.ts) #### `llm/retry-started` — log-only @@ -426,7 +426,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:20`](../packages/llm/llm-retry/src 'llm/retry-started': LlmRetryStartedEventData ``` -Source: [`packages/llm/llm-retry/src/index.ts:22`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts) ### `permission/*` @@ -648,12 +648,10 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } +'tool/code-dispatch': CodeDispatchEventData ``` -Types: [CallId](subsystems/core.md) · [ContentBlock](subsystems/core.md) - -Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) #### `tool/code-dispatch-start` — log-only @@ -671,12 +669,10 @@ Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/c * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } +'tool/code-dispatch-start': CodeDispatchStartEventData ``` -Types: [CallId](subsystems/core.md) - -Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) #### `tool/result` — surface diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 9466165680..32c5f15c15 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.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/commands.md -commands.md: 7f4e4e87d7206be3e46c7076ee5e8ab6a7589f9a -commands.zh.md: 0f8ff31bca5f62824912f53aee8dbc32db4c331b +commands.md: 5e280d659c2a74b3d8ba20f362922e9eb4e0b84c +commands.zh.md: 24f099d1926f7dba45122bdc53c23a4274e3f507 diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index 7f4e4e87d7..5e280d659c 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -161,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -183,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 0f8ff31bca..24f099d192 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -161,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -183,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 2fb9c399be..70ceac3b11 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/core.md -core.md: ec9a966164f2b706ae16341c628fb8f849eb7382 -core.zh.md: 4de168fd9714a935b4d9b08bbabd52492d1ea1cc +core.md: 27c4359e360223d336cd94695bb45a79f0fd370c +core.zh.md: 4e7519665b6d9efb8075546d93325debd961905d diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index ec9a966164..27c4359e36 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -547,7 +547,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) @@ -575,7 +575,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:159`](../../packages/core/agent/src/runtime-types.ts) @@ -597,7 +597,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:168`](../../packages/core/agent/src/runtime-types.ts) @@ -621,7 +621,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:290`](../../packages/core/agent/src/runtime-types.ts) @@ -645,7 +645,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:197`](../../packages/core/agent/src/runtime-types.ts) @@ -666,7 +666,7 @@ One message was discarded from the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:205`](../../packages/core/agent/src/runtime-types.ts) @@ -687,7 +687,7 @@ One message entered the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:186`](../../packages/core/agent/src/runtime-types.ts) @@ -712,7 +712,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:231`](../../packages/core/agent/src/runtime-types.ts) @@ -738,7 +738,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:244`](../../packages/core/agent/src/runtime-types.ts) @@ -767,7 +767,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:260`](../../packages/core/agent/src/runtime-types.ts) @@ -791,7 +791,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:217`](../../packages/core/agent/src/runtime-types.ts) @@ -814,7 +814,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:178`](../../packages/core/agent/src/runtime-types.ts) @@ -845,7 +845,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:278`](../../packages/core/agent/src/runtime-types.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 4de168fd97..4e7519665b 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -555,7 +555,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) @@ -583,7 +583,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:159`](../../packages/core/agent/src/runtime-types.ts) @@ -605,7 +605,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:168`](../../packages/core/agent/src/runtime-types.ts) @@ -629,7 +629,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:290`](../../packages/core/agent/src/runtime-types.ts) @@ -653,7 +653,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:197`](../../packages/core/agent/src/runtime-types.ts) @@ -674,7 +674,7 @@ One message was discarded from the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:205`](../../packages/core/agent/src/runtime-types.ts) @@ -695,7 +695,7 @@ One message entered the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:186`](../../packages/core/agent/src/runtime-types.ts) @@ -720,7 +720,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:231`](../../packages/core/agent/src/runtime-types.ts) @@ -746,7 +746,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:244`](../../packages/core/agent/src/runtime-types.ts) @@ -775,7 +775,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:260`](../../packages/core/agent/src/runtime-types.ts) @@ -799,7 +799,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:217`](../../packages/core/agent/src/runtime-types.ts) @@ -822,7 +822,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:178`](../../packages/core/agent/src/runtime-types.ts) @@ -853,7 +853,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:278`](../../packages/core/agent/src/runtime-types.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index d015cdb638..1384b3888b 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.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/tools.md -tools.md: f06db58b9b52fcb2c325ff813406cd2dc460ea7b -tools.zh.md: cca4bf9faa870d9414d95f785b67e0393e469cdd +tools.md: 83389f39c3188fc251504ed5786249ff1921acae +tools.zh.md: 45cb85b3f2940f84b46bc58406bc8255cbe08be7 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index f06db58b9b..83389f39c3 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -554,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) @@ -579,7 +579,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:191`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) @@ -605,7 +605,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) @@ -629,7 +629,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts) @@ -654,7 +654,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts) @@ -677,7 +677,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:137`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) @@ -698,5 +698,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index cca4bf9faa..45cb85b3f2 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -554,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) @@ -579,7 +579,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:191`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) @@ -605,7 +605,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) @@ -629,7 +629,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts) @@ -654,7 +654,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts) @@ -677,7 +677,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:137`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) @@ -698,5 +698,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) From e11f630fcdbd155dcf94f35d208a8b0516703f23 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:12:54 +0800 Subject: [PATCH 16/32] fix(client): address remaining conversation review feedback --- ...lient-conversation-node-assembly.i18n.yaml | 4 +-- ...08-09-client-conversation-node-assembly.md | 2 ++ ...09-client-conversation-node-assembly.zh.md | 2 ++ docs/subsystems/compaction.i18n.yaml | 4 +-- docs/subsystems/compaction.md | 2 +- docs/subsystems/compaction.zh.md | 2 +- .../src/client/chat/TurnTailNodeView.tsx | 6 ++-- .../src/client/contract/chat-nodes.ts | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 36 ++++++++++++++----- .../src/client/turn-deliverables.ts | 4 ++- vitest.config.ts | 1 + 11 files changed, 47 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index d19cd86c7d..b76951347b 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 9952f8fac0f2a3b0fe72cc97ae9a8fb4f96ace08 -2026-08-09-client-conversation-node-assembly.zh.md: f27a82459cdacdb4a2da27cf12b30c93f2dfdf64 +2026-08-09-client-conversation-node-assembly.md: 16a39539064644e5467f701789a7e2ef1f7ff172 +2026-08-09-client-conversation-node-assembly.zh.md: 0e0fbdf8f3320393022528e6e3fe2cf0d492a1d3 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 9952f8fac0..16a3953906 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -394,6 +394,8 @@ History-path tests cover complete replace, non-overlapping prepend, overlapping- A new business node can register its matcher, State transitions, optional Location data, final target Node, and renderer locally without changing Session's business switch. `ChatNodeDataMap` and the Location data maps let a business package merge strongly typed data into the contract; every related Event must still expose a stable ID derivable from that Event alone. +Host business packages declaration-merge their durable Event members into `@deepseek-ai/dsh-session/types`, while Client Definitions type-only import the corresponding business package `/types` subpaths. Augmenting the declaring interface rather than a re-export barrel gives the independent Host and Client TypeScript programs the same Event narrowing without pulling Host runtime into the Client graph. + Initial tail, older prepend, and live append share one set of Context invariants. Missing starts, Reader window gaps, unknown Locations, and high-frequency deltas are explicit engine states and require no direction-specific business cache. Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index f27a82459c..0e0fbdf8f3 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -394,6 +394,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏 新增业务节点可以局部注册自己的 matcher、State 转换、可选 Location data、最终 target Node 和 renderer,不再修改 Session 的业务 switch。`ChatNodeDataMap` 和 Location data maps 允许业务 package 通过 declaration merging 合入强类型 data;所有相关 Event 仍须暴露可单 Event 推导的稳定 ID。 +Host 业务 package 把自己的持久 Event 成员 declaration-merge 到 `@deepseek-ai/dsh-session/types`,Client Definition 则通过对应业务 package 的 `/types` 子路径进行 type-only import。增强实际声明接口而不是重导出 barrel,使 Host 和 Client 的独立 TypeScript Program 都能获得相同的 Event narrowing,同时不把 Host runtime 带入 Client 图。 + 初始尾页、older prepend 和 live append 共享一套 Context 不变量。缺 start、Reader window gap、Location unknown 以及高频 delta 都是引擎明确表达的状态,不需要业务另建方向相关 cache。 Append 不扫描历史 Context;prepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。 diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index fd8dbdd1b5..532750f069 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.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/compaction.md -compaction.md: 1aca48d677a83a488d9590edad3f8a68ca662b70 -compaction.zh.md: e2cfabc0891126d9f2165e47dffcf485f4a8a339 +compaction.md: 11ee6eae797db1c57bf2ffbb90f1f37f47b8400c +compaction.zh.md: a560e47dbb7aa92f0757179534803bbcaaebc6ba diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 1aca48d677..11ee6eae79 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -20,7 +20,7 @@ The lock brackets the **whole** operation: `compact/start` is appended first, th The markers are lock time points, not an exclusive container. An unrelated idle injection can appear between a standalone manual start and end while summarization is pending. The manual path revalidates only its selected positional span, so that injected context survives after the replacement checkpoint. A live unmatched start blocks every entry point; an unmatched start before a newer `session/end-seed` is stale evidence from a prior lifecycle and is ignored. -These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes. +These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes. ## `CompactionResult` diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index e2cfabc089..a560e47dbb 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -20,7 +20,7 @@ 这些标记表示锁的时间点,而不是排他的容器。摘要等待期间,不相关的空闲注入可以出现在独立的手动 start 与 end 之间。手动路径只重新验证所选位置 span,因此替换检查点之后仍保留该注入上下文。活动的未匹配 start 会阻塞所有入口点;较新 `session/end-seed` 之前的未匹配 start 是先前生命周期留下的陈旧证据,会被忽略。 -这些变体在 `declare module '@deepseek-ai/dsh-session'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 +这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 ## `CompactionResult` diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx index bf48fc75c6..444389e6ea 100644 --- a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx @@ -9,9 +9,11 @@ type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<' /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlotChain, t, + node, openFile, forkAt, renderSlotChain, t, useSession, }: TurnTailNodeViewProps) { const data = node.data + const hasLaterChatNode = useSession(snapshot => + snapshot.chat.locations.getTurn(data.turn).at(-1) !== node.key) const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined @@ -34,7 +36,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ tokensPerSecond={data.tokensPerSecond} clock="end" onBranch={() => { forkAt(closing.finalNode.seq) }} - branchUnavailable={data.branchUnavailable} + branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts index 787f391006..c057d15d1b 100644 --- a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts @@ -57,7 +57,7 @@ export interface TurnTailChatData { readonly time: number /** Last finalized content-bearing Assistant in this Turn. */ readonly closing: FinalAssistantChatData | null - /** Whether later Assistant/Step material makes the closing seq non-tail. */ + /** Whether non-rendered later evidence makes the closing seq non-tail. */ readonly branchUnavailable: boolean readonly ttftMs?: number readonly tokensPerSecond?: number diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index ca6223826e..6b2072ddfb 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -181,14 +181,12 @@ function makeHarness(init?: Partial) { if (key !== 'conversation.chat.node') return opts?.fallback ?? null const nodeOwner = owner as RoutedChatNodeOwner const nodeKey = opts?.hookContext as string | undefined - const useTurnData = ((dataKey: string) => { - return props.useSession((snapshot) => { - const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location - return location?.kind === 'turn' || location?.kind === 'step' - ? location.turn.data.get(dataKey as never) - : undefined - }) - }) as UseChatNodeTurnData + const useTurnData: UseChatNodeTurnData = dataKey => props.useSession((snapshot) => { + const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(dataKey) + : undefined + }) const nodeProps = (): ChatNodeViewProps => ( { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps ) @@ -722,6 +720,28 @@ describe('ChatView', () => { expect(h.forkAt.mock.calls).toEqual([[2]]) }) + it('disables fork when the indexed Turn has a later steering Node', () => { + const base = chatSnapshotFixture({ + nodes: [user(1, 'question'), assistant(2, 'answer')], + turnEnds: new Map([[1, 4]]), + }) + const chat = { + ...base, + locations: { + getTurn: (turn: number) => turn === 1 + ? [...base.locations.getTurn(turn), 'fixture:steering:later'] + : base.locations.getTurn(turn), + getStep: (turn: number, step: number) => base.locations.getStep(turn, step), + }, + } + const h = makeHarness({ chat }) + const view = render() + const branch = view.getByRole('button', { name: '在新对话中分支' }) + expect(branch.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(branch) + expect(h.forkAt).not.toHaveBeenCalled() + }) + it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => { const interruptedThink: AssistantMessageNode = { kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 0227b34261..9151f88869 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -36,7 +36,9 @@ interface DeliverablesState extends DeliverablesTurnData { * Paths a call view reports having created or changed, by render intent rather * than tool name: a diff card, or a generic card whose kind is `edit` (the * shape `str_replace_editor`'s insert presents). Every other card produces - * nothing to open — a read looked, a delete removed, a terminal ran. + * nothing to open — a read looked, a delete removed, a terminal ran. Only + * root call views enter this Turn accumulator; nested Code Mode dispatches + * preserve the pre-assembly behavior and do not contribute independently. */ function producedPaths(view: ToolResultNode['callView']): readonly string[] { if (view === null) return [] diff --git a/vitest.config.ts b/vitest.config.ts index e07ae43ec3..33512f4898 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -211,6 +211,7 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/index.ts', 'packages/client/test-runtime/src/translate.ts', 'packages/client/ui-primitives/src/JsonTree.tsx', + 'packages/client/ui-deliverables/src/client/turn-deliverables.ts', // Typert generator: correctness is pinned by its fixture suites and // the byte-for-byte catalog reproduction test; per-file coverage // would put whole-workspace compiler analysis under v8 From 126ad5bb02147a5d584349fbce4111e274cea8ff Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:36:15 +0800 Subject: [PATCH 17/32] docs: development --- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 3 +- docs/architecture.zh.md | 3 +- .../adding-a-conversation-node.i18n.yaml | 6 + docs/cookbook/adding-a-conversation-node.md | 232 ++++++++++++++++ .../cookbook/adding-a-conversation-node.zh.md | 232 ++++++++++++++++ docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 3 +- docs/cookbook/extension-cookbook.zh.md | 3 +- docs/event-producer-consumer.i18n.yaml | 2 +- docs/event-producer-consumer.zh.md | 44 +-- docs/module-graph.i18n.yaml | 2 +- docs/module-graph.zh.md | 253 +++++++++--------- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.zh.md | 66 ++--- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 2 + docs/subsystems/session.zh.md | 2 + packages/client/AGENTS.md | 6 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + scripts/doc-budgets.manifest.json | 2 +- website/docs.ts | 8 + 27 files changed, 698 insertions(+), 201 deletions(-) create mode 100644 docs/cookbook/adding-a-conversation-node.i18n.yaml create mode 100644 docs/cookbook/adding-a-conversation-node.md create mode 100644 docs/cookbook/adding-a-conversation-node.zh.md diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index dff7978387..fe32a219de 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 506283bfc651d38fe4bccd50fb8a86143d2b41d8 -architecture.zh.md: ea48af8131ebbdc70a45e403dfddc78dfcfb6f47 +architecture.md: 771d7489ee338db56362a6ccc133b1ebf8cdc7c0 +architecture.zh.md: ca7c4fe2a463e01a8e14e63a53f13aea45cbfa16 diff --git a/docs/architecture.md b/docs/architecture.md index 506283bfc6..771d7489ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,10 +183,11 @@ New behavior attaches to a documented extension point; a loop change updates thi | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary | | Add model-facing context | call `agent.inject()` to queue sourced context for the next admitted request | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | +| Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer | | Add durable session state | extend `SessionEventMap`; render and replay from the log | | Add asynchronous session-title generation | register the sole `ctx.sessionTitle` provider | | Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | | Fork a live session | call `ctx.sessions.fork(source, boundary?, childSessionId?)` | | Scope a registration to one agent | use its `agent.ctx` (see Agent Scope) | -The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). +[Extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ea48af8131..ca7c4fe2a4 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -183,10 +183,11 @@ idle inject: | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止边界 | | 添加模型可见上下文 | 调用 `agent.inject()`,将带来源的上下文排入下一次获准请求 | | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | +| Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer | | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | | 添加异步会话标题生成 | 注册唯一的 `ctx.sessionTitle` 提供方 | | 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent` 和 `agent/*` 续跑 | | fork 活跃会话 | 调用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | | 将注册项限定到单个 agent | 使用其 `agent.ctx`(参见 Agent 作用域) | -[扩展实操手册(cookbook)](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 +[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 diff --git a/docs/cookbook/adding-a-conversation-node.i18n.yaml b/docs/cookbook/adding-a-conversation-node.i18n.yaml new file mode 100644 index 0000000000..aa268e9461 --- /dev/null +++ b/docs/cookbook/adding-a-conversation-node.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 docs/cookbook/adding-a-conversation-node.md +adding-a-conversation-node.md: ea4ec73eb109af6b0e4c7cf50fc8692942c75dd4 +adding-a-conversation-node.zh.md: 4b9a8049e2f1d060ec4bc3334036559b989ea562 diff --git a/docs/cookbook/adding-a-conversation-node.md b/docs/cookbook/adding-a-conversation-node.md new file mode 100644 index 0000000000..ea4ec73eb1 --- /dev/null +++ b/docs/cookbook/adding-a-conversation-node.md @@ -0,0 +1,232 @@ +# Add a Web Client conversation node + +English | [中文](adding-a-conversation-node.zh.md) + +This tutorial adds one business-owned row to the Web Client Chat view. The finished plugin correlates a durable Session event family into one Context, incrementally builds business State, publishes typed Step data, and renders a keyed Chat Node without scanning the Session window or other rendered nodes. It assumes the Host already records the events and the client plugin is composed into the Web bundle; external Host-side UIs and additional view targets such as Trajectory are outside this tutorial. + +The [Conversation Node assembly decision](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md) owns the rationale and complete engine model. This guide covers the implementation path. + +## 1. Design a replayable event family + +Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context. + +For a review job, the event contract could be: + +| Event | Role | Required durable facts | +|---|---|---| +| `review/start` | unique start | `reviewId`, Turn/Step coordinates, title | +| `review/progress` | update | the same `reviewId`, coordinates, replayable progress | +| `review/end` | update | the same `reviewId`, coordinates, final summary | + +Use the producer-owned branded id type across the process boundary. Put the `SessionEventMap` merge and payload types on the producer's type-only export, then import that export for side effects from the client package. Each `(kind, id)` may have at most one start event. A single-event business can use the event's stable identity, such as `event.seq`, as its Definition-local id. + +Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events. + +## 2. Implement the Definition and typed Chat payload + +The example keeps the producer declarations and client contribution in one block so the complete relationship is visible. In a package family, keep the branded id and `SessionEventMap` declaration with the event producer, and keep the Definition, Chat data merge, and renderer in the client plugin. + +```ts ignore-check +import { createElement } from 'react' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { + ClientContext, ConversationLocation, ConversationNodeContext, + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' + +type ReviewId = Branded<'ReviewId'> + +interface ReviewStartData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly title: string +} + +interface ReviewProgressData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly completed: number +} + +interface ReviewEndData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly summary: string +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Opens one durable review job. + * @mode emit + * @param data - stable identity, location, and initial display state. + */ + 'review/start': ReviewStartData + /** + * Records replayable progress for one review job. + * @mode emit + * @param data - stable identity, location, and latest progress. + */ + 'review/progress': ReviewProgressData + /** + * Closes one review job with its final summary. + * @mode emit + * @param data - stable identity, location, and final display state. + */ + 'review/end': ReviewEndData + } +} + +interface ReviewChatData { + readonly title: string + readonly completed: number + readonly status: 'running' | 'completed' + readonly summary?: string +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + 'review-job': ReviewChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationStepDataMap { + 'review-job': ReviewChatData + } +} + +interface ReviewState extends ReviewChatData { + readonly turn: number + readonly step: number +} + +function locationOf(context: ConversationNodeContext): ConversationLocation { + return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } +} + +function viewData(state: ReviewState): ReviewChatData { + return { + title: state.title, + completed: state.completed, + status: state.status, + ...state.summary === undefined ? {} : { summary: state.summary }, + } +} + +const reviewDefinition: ConversationNodeDefinition = { + kind: 'review-job', + match: (event) => { + if (event.type === 'review/start') { + return { id: String(event.data.reviewId), role: 'start' } + } + if (event.type === 'review/progress' || event.type === 'review/end') { + return { id: String(event.data.reviewId), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'review/start') throw new Error('review-job requires review/start') + return { + turn: match.event.data.turn, + step: match.event.data.step, + title: match.event.data.title, + completed: 0, + status: 'running', + } + }, + update: (context, match) => { + if (match.event.type === 'review/progress') { + return { ...context.state, completed: match.event.data.completed } + } + if (match.event.type === 'review/end') { + return { ...context.state, completed: 100, status: 'completed', summary: match.event.data.summary } + } + return context.state + }, + publication: match => match.event.type === 'review/progress' + ? 'animation-frame' + : 'immediate', + buildLocationData: (context, scope) => { + if (scope !== 'step' || context.state === undefined) return null + return { + kind: 'step', + turn: context.state.turn, + step: context.state.step, + key: 'review-job', + value: viewData(context.state), + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined) return null + return { + key: context.key, + kind: 'review-job', + id: context.id, + target: 'chat', + anchorSeq: context.start?.event.seq ?? context.matches[0]?.event.seq ?? 0, + location: locationOf(context), + visibility: 'visible', + data: viewData(context.state), + } + }, +} + +function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) { + const text = node.data.summary ?? `${node.data.title}: ${node.data.completed}%` + return createElement('p', null, text) +} + +export const inject = ['conversationEvents', 'slots'] + +export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(reviewDefinition) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'review-job', + }, ReviewNodeView)) +} +``` + +`match(event)` is an identity extractor, not a fold: it receives only the current event and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and calls `start` once or `update` with the current State. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics. + +`buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`. + +`buildViewNode(context, target)` materializes the final target-specific Node. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. + +## 3. Query an earlier business Context only at start + +Some Definitions need the latest earlier State of another business kind. `start` receives a `ConversationContextReader`; call `reader.previous(kind)` there instead of accepting a Context collection or scanning events. The reader returns the nearest started Context before the current start `seq` as read-only data. + +The assembler records that dependency. If an older prepend later supplies a nearer predecessor, closes a previously unknown window gap, or revises the predecessor State, it reruns the dependent Context from `start` and replays its updates in ascending `seq`. The queried Definition remains responsible for writing useful State; the reader exposes no business-specific query methods and grants no mutation authority over another Context. + +## 4. Understand the three ingestion paths + +History may be requested from the tail backward one page at a time, but every accepted page is normalized into ascending `seq` before State replay. + +| Path | Engine work | Definition-visible behavior | +|---|---|---| +| Replace on open, resync, or gap repair | Rebuild the loaded window, match every event once per Definition, then replay each started Context | `start`, followed by its updates in ascending `seq`; pending update-only Contexts remain without State | +| Prepend one older page | Match only fresh older events, merge them into Contexts by `(kind, id)`, preserve existing keyed nodes, and replay only affected Contexts and dependencies | A newly found start activates its collected updates; a changed Location or predecessor may rerun the Context | +| Append one live event | Call each Definition's `match` once, look up the matched Context by key, and update only that Context | One `update` and one requested publication for a matching post-start event; no existing Context scan | + +With `D` registered Definitions, one incoming event performs `D` current-event matches and constant-time Context-key lookup after a match. Definition code must preserve that property: do not traverse the complete event window, every Context, `context.matches`, or the rendered Node collection on the normal append path. Use State for accumulated facts, Location data for same-Turn/Step sharing, and `reader.previous()` for indexed predecessor dependencies. + +`publication` controls when changed State is materialized. Use `immediate` for structural or terminal changes, `animation-frame` for high-frequency visible deltas, and `none` when the State change feeds only a later publication. The engine still applies every update in log order; cadence only coalesces view publication. + +## 5. Verify replay, pagination, and rendering + +Add focused tests that establish these outcomes: + +1. A complete window passed through replace produces the expected final State, Location data, Node payload, and `anchorSeq`. +2. An update-only tail stays pending; prepending the unique start produces the same result as a complete replace. +3. Initial history followed by live append produces the same result as replaying the combined window. +4. Prepending an older page adds earlier rows without replacing existing keyed Node values whose data did not change. +5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested. +6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes. + +Use [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node. diff --git a/docs/cookbook/adding-a-conversation-node.zh.md b/docs/cookbook/adding-a-conversation-node.zh.md new file mode 100644 index 0000000000..4b9a8049e2 --- /dev/null +++ b/docs/cookbook/adding-a-conversation-node.zh.md @@ -0,0 +1,232 @@ +# 添加 Web Client Conversation Node + +[English](adding-a-conversation-node.md) | 中文 + +本教程为 Web Client Chat 视图添加一行由业务自行拥有的内容。完成后的插件会把一个持久 Session 事件族关联成一个 Context,增量构造业务 State,发布类型化 Step 数据,再渲染 keyed Chat Node;整个过程不扫描 Session 窗口或其他已渲染节点。本教程假设 Host 已经记录这些事件,且该 Client 插件已组装进 Web bundle;Host 侧外部 UI 和 Trajectory 等额外视图目标不在本文范围内。 + +[Conversation Node 组装决策](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md)记录完整的引擎模型和设计理由;本文只说明实现路径。 + +## 1. 设计可回放的事件族 + +编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。 + +以一个 review job 为例,事件约定可以是: + +| 事件 | 角色 | 必须持久化的事实 | +|---|---|---| +| `review/start` | 唯一 start | `reviewId`、Turn/Step 坐标、标题 | +| `review/progress` | update | 相同的 `reviewId`、坐标、可回放进度 | +| `review/end` | update | 相同的 `reviewId`、坐标、最终摘要 | + +跨进程边界使用生产方拥有的 branded id 类型。把 `SessionEventMap` 合并和 payload 类型放在生产方的纯类型导出中,再由 Client 包通过仅类型副作用导入该导出。每个 `(kind, id)` 最多只能有一条 start 事件。单事件业务可以把事件自身的稳定身份(例如 `event.seq`)作为 Definition 内部 id。 + +系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。 + +## 2. 实现 Definition 与类型化 Chat payload + +为了完整展示关联关系,下面把生产方声明和 Client 贡献写在同一个代码块里。实际的包族中,branded id 与 `SessionEventMap` 声明留在事件生产方,Definition、Chat data 合并与 renderer 留在 Client 插件。 + +```ts ignore-check +import { createElement } from 'react' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { + ClientContext, ConversationLocation, ConversationNodeContext, + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' + +type ReviewId = Branded<'ReviewId'> + +interface ReviewStartData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly title: string +} + +interface ReviewProgressData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly completed: number +} + +interface ReviewEndData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly summary: string +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Opens one durable review job. + * @mode emit + * @param data - stable identity, location, and initial display state. + */ + 'review/start': ReviewStartData + /** + * Records replayable progress for one review job. + * @mode emit + * @param data - stable identity, location, and latest progress. + */ + 'review/progress': ReviewProgressData + /** + * Closes one review job with its final summary. + * @mode emit + * @param data - stable identity, location, and final display state. + */ + 'review/end': ReviewEndData + } +} + +interface ReviewChatData { + readonly title: string + readonly completed: number + readonly status: 'running' | 'completed' + readonly summary?: string +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + 'review-job': ReviewChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationStepDataMap { + 'review-job': ReviewChatData + } +} + +interface ReviewState extends ReviewChatData { + readonly turn: number + readonly step: number +} + +function locationOf(context: ConversationNodeContext): ConversationLocation { + return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } +} + +function viewData(state: ReviewState): ReviewChatData { + return { + title: state.title, + completed: state.completed, + status: state.status, + ...state.summary === undefined ? {} : { summary: state.summary }, + } +} + +const reviewDefinition: ConversationNodeDefinition = { + kind: 'review-job', + match: (event) => { + if (event.type === 'review/start') { + return { id: String(event.data.reviewId), role: 'start' } + } + if (event.type === 'review/progress' || event.type === 'review/end') { + return { id: String(event.data.reviewId), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'review/start') throw new Error('review-job requires review/start') + return { + turn: match.event.data.turn, + step: match.event.data.step, + title: match.event.data.title, + completed: 0, + status: 'running', + } + }, + update: (context, match) => { + if (match.event.type === 'review/progress') { + return { ...context.state, completed: match.event.data.completed } + } + if (match.event.type === 'review/end') { + return { ...context.state, completed: 100, status: 'completed', summary: match.event.data.summary } + } + return context.state + }, + publication: match => match.event.type === 'review/progress' + ? 'animation-frame' + : 'immediate', + buildLocationData: (context, scope) => { + if (scope !== 'step' || context.state === undefined) return null + return { + kind: 'step', + turn: context.state.turn, + step: context.state.step, + key: 'review-job', + value: viewData(context.state), + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined) return null + return { + key: context.key, + kind: 'review-job', + id: context.id, + target: 'chat', + anchorSeq: context.start?.event.seq ?? context.matches[0]?.event.seq ?? 0, + location: locationOf(context), + visibility: 'visible', + data: viewData(context.state), + } + }, +} + +function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) { + const text = node.data.summary ?? `${node.data.title}: ${node.data.completed}%` + return createElement('p', null, text) +} + +export const inject = ['conversationEvents', 'slots'] + +export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(reviewDefinition) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'review-job', + }, ReviewNodeView)) +} +``` + +`match(event)` 是身份提取器,不是 fold:它只能收到当前事件,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context,再调用一次 `start`,或把当前 State 交给 `update`。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。 + +`buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。 + +`buildViewNode(context, target)` 物化最终的目标专用 Node。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 + +## 3. 只在 start 时查询更早的业务 Context + +有些 Definition 需要另一个业务 kind 在当前位置之前的最新 State。`start` 会收到 `ConversationContextReader`;应在这里调用 `reader.previous(kind)`,不要接收 Context 集合或扫描事件。Reader 返回当前 start `seq` 之前最近一个已启动 Context 的只读数据。 + +Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的前序 Context、补齐了原先未知的窗口缺口,或者前序 State 被修订,引擎会从 `start` 重新运行依赖方 Context,并按 `seq` 升序回放其 update。被查询的 Definition 仍负责把有用信息写入自身 State;Reader 不提供业务专用查询方法,也不授予修改其他 Context 的权限。 + +## 4. 理解三条摄入路径 + +历史可能从尾部开始一页一页向前请求,但每个已接收分页都会先按 `seq` 升序归一化,再进入 State 回放。 + +| 路径 | 引擎工作 | Definition 可观察到的行为 | +|---|---|---| +| open、resync 或 gap repair 时 replace | 重建已加载窗口,每条事件对每个 Definition 匹配一次,再回放每个已有 start 的 Context | 先执行 `start`,再按 `seq` 升序执行其 update;只有 update 的 pending Context 仍没有 State | +| prepend 一页更早历史 | 只匹配新增的更早事件,按 `(kind, id)` 合并进 Context,保留现有 keyed node,并只重放受影响的 Context 与依赖 | 新发现的 start 会激活已收集 update;Location 或前序依赖变化也可能重跑 Context | +| append 一条实时事件 | 每个 Definition 各调用一次 `match`,按 key 查找命中的 Context,只更新该 Context | 对 start 之后的匹配事件执行一次 `update` 并请求一次发布;不扫描已有 Context | + +注册 `D` 个 Definition 时,一条新事件会进行 `D` 次仅当前事件匹配;命中后的 Context key 查询是常数时间。Definition 代码必须维持这个性质:正常 append 热路径不得遍历完整事件窗口、所有 Context、`context.matches` 或已渲染 Node 集合。累计事实放进 State,同 Turn/Step 共享信息放进 Location data,有索引的前序依赖使用 `reader.previous()`。 + +`publication` 控制发生 State 变更后何时物化。结构或 terminal 变化使用 `immediate`,高频可见 delta 使用 `animation-frame`,只为后续发布积累 State 时使用 `none`。引擎仍会按日志顺序应用每条 update;该选项只合并视图发布频率。 + +## 5. 验证回放、分页与渲染 + +添加聚焦测试,证明以下结果: + +1. 完整窗口通过 replace 后产生预期的最终 State、Location data、Node payload 与 `anchorSeq`。 +2. 只有 update 的尾部窗口保持 pending;prepend 唯一 start 后,结果与完整 replace 相同。 +3. 初始历史后继续实时 append,与回放合并后的完整窗口得到相同结果。 +4. prepend 更早分页只增加更早的行;数据未变化的既有 keyed Node value 不被替换。 +5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。 +6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。 + +流式与中断处理可参考 [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 0509a67549..7eaa702a9d 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.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/cookbook/extension-cookbook.md -extension-cookbook.md: 025a1b6ecd11593b5d6be9d0f64e57ac8b5e3139 -extension-cookbook.zh.md: f838a281fabdba473b72b27f7b14274d9aa97528 +extension-cookbook.md: 5d9312f2f5cf840100b12045bde1829b342e580d +extension-cookbook.zh.md: 29bb57c558a0ff9413619d0bb2e8bb5e6b70da56 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 025a1b6ecd..5d9312f2f5 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -34,7 +34,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an ## A UI plugin -A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. +A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. A browser plugin contributing a business row to the built-in Web Client instead registers a `ConversationNodeDefinition` and keyed Chat renderer; follow the [Conversation Node guide](adding-a-conversation-node.md). ```ts import type { Context } from 'cordis' @@ -123,6 +123,7 @@ Every product feature maps to a listener on a documented extension point — the | Memory | section provider + tool | | Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | | UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` | +| Web Client Chat business node | register a `ConversationNodeDefinition` and `conversation.chat.node` keyed renderer | | Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | | Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) | | Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index f838a281fa..29bb57c558 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -34,7 +34,7 @@ export function apply(ctx: Context) { ## UI 插件 -UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。 +UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。如果浏览器插件要向内建 Web Client 贡献业务行,则应注册 `ConversationNodeDefinition` 与 keyed Chat renderer;具体步骤见 [Conversation Node 指南](adding-a-conversation-node.md)。 ```ts import type { Context } from 'cordis' @@ -123,6 +123,7 @@ export function apply(ctx: Context) { | 记忆 | section 提供方 + 工具 | | 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | | UI(GUI;CLI(命令行界面)输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` | +| Web Client Chat 业务节点 | 注册 `ConversationNodeDefinition` 与 `conversation.chat.node` keyed renderer | | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | | 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) | | 插件热重载 | 每个注册都是一个 `ctx.effect` → 随仓库提供的 HMR(热模块替换)直接生效 | diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 812b490bef..89e1b03d35 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -3,4 +3,4 @@ # 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: 05ff62a391a0acb7db9abd2c9ce0c2082da52eaa -event-producer-consumer.zh.md: 2d4c805f9a5d3b0531ee59eebe9e6564347f0a00 +event-producer-consumer.zh.md: 7eafe5f2880cf9f0e6f2e7eb6c299730926f15ed diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2d4c805f9a..7eafe5f288 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,20 +10,20 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:134`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -46,12 +46,12 @@ | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../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:35`](../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`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:137`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -59,9 +59,9 @@ | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -## 包源码中出现的非 harness 或未声明事件字符串 +## Non-harness or undeclared event strings seen in package source -| 事件字符串 | 派发方 | 监听方 | +| Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | @@ -80,4 +80,4 @@ | `slots/changed` | `runtime` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | -维护模式:英文源文件是生成内容,Cordis 事件声明及生产方/监听方的关系边由仓库的 TypeScript Program 解析;本中文文件作为经评审对侧通过双语配对维护。 +Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 24ddaad77c..61e55cd997 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md module-graph.md: a248ed4fcb8abc17ffc6982d2733b3c3c2a2a635 -module-graph.zh.md: 255253bcfa9cfad1ab85602dfa9f953a02dfc427 +module-graph.zh.md: 28185c255ffa18f3ebc02f177d20594af3356164 diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 255253bcfa..28185c255f 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -438,9 +438,6 @@ flowchart TD pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill - pkg_compact --> pkg_invariants - pkg_compact --> pkg_llm - pkg_compact --> pkg_session pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web @@ -504,15 +501,11 @@ flowchart TD pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout - pkg_token_meter --> pkg_compact - pkg_token_meter --> pkg_invariants - pkg_token_meter --> pkg_llm - pkg_token_meter --> pkg_session - pkg_token_meter --> pkg_session_projection pkg_agent_default_model --> pkg_agent pkg_agent_default_model --> pkg_invariants pkg_agent_default_model --> pkg_llm @@ -552,10 +545,6 @@ flowchart TD pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session - pkg_llm_replay --> pkg_compact - pkg_llm_replay --> pkg_invariants - pkg_llm_replay --> pkg_llm - pkg_llm_replay --> pkg_session pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm @@ -676,14 +665,11 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_command_compact --> pkg_commands - pkg_command_compact --> pkg_compact - pkg_command_compact --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_compact - pkg_compact_tool_result_prune --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_llm - pkg_compact_tool_result_prune --> pkg_session - pkg_compact_tool_result_prune --> pkg_token_meter + pkg_compact --> pkg_brand + pkg_compact --> pkg_commands + pkg_compact --> pkg_invariants + pkg_compact --> pkg_llm + pkg_compact --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -705,13 +691,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -744,6 +723,11 @@ flowchart TD pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout + pkg_token_meter --> pkg_compact + pkg_token_meter --> pkg_invariants + pkg_token_meter --> pkg_llm + pkg_token_meter --> pkg_session + pkg_token_meter --> pkg_session_projection pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -792,13 +776,9 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_compact_tool_result_prune - pkg_compact_basic --> pkg_invariants - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session - pkg_compact_basic --> pkg_token_meter + pkg_command_compact --> pkg_commands + pkg_command_compact --> pkg_compact + pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants @@ -859,33 +839,10 @@ flowchart TD pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_tool --> pkg_client_locale - pkg_client_ui_tool --> pkg_client_runtime - pkg_client_ui_tool --> pkg_client_ui_conversation - pkg_client_ui_tool --> pkg_client_ui_primitives - pkg_client_ui_tool --> pkg_client_ui_slots - pkg_client_ui_tool --> pkg_invariants + pkg_llm_replay --> pkg_compact + pkg_llm_replay --> pkg_invariants + pkg_llm_replay --> pkg_llm + pkg_llm_replay --> pkg_session pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -992,6 +949,11 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_compact_tool_result_prune --> pkg_compact + pkg_compact_tool_result_prune --> pkg_invariants + pkg_compact_tool_result_prune --> pkg_llm + pkg_compact_tool_result_prune --> pkg_session + pkg_compact_tool_result_prune --> pkg_token_meter pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1040,50 +1002,18 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_client_ui_tool - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_commands + pkg_client_ui_conversation --> pkg_compact + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tools pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1107,6 +1037,14 @@ flowchart TD pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_commands + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_compact_tool_result_prune + pkg_compact_basic --> pkg_invariants + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session + pkg_compact_basic --> pkg_token_meter pkg_subagent_codex --> pkg_invariants pkg_subagent_codex --> pkg_llm pkg_subagent_codex --> pkg_sdk_protocol @@ -1122,6 +1060,50 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_runtime + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_primitives + pkg_client_ui_tool --> pkg_client_ui_slots + pkg_client_ui_tool --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1163,6 +1145,33 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1240,7 +1249,6 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | -| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | @@ -1257,8 +1265,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1269,7 +1276,6 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1297,19 +1303,18 @@ flowchart TD | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | @@ -1317,7 +1322,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | @@ -1327,10 +1332,7 @@ flowchart TD | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1349,6 +1351,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1357,20 +1360,26 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/scaffold/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d814b6ac6e..e676227869 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -3,4 +3,4 @@ # 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: c9aa15cd827d99cee64e7a33db11995ee17f9bf8 -persistence-catalog.zh.md: 364912b88c3b2c5efd92a616034be9ef5025ee67 +persistence-catalog.zh.md: d8743a1b0f26f2a4cf50aed42eb68f6c6db1ba13 diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 364912b88c..d8743a1b0f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5,7 +5,7 @@ [English](persistence-catalog.md) | 中文 -会话持久事件日志中可能出现的所有事件类型:完整持久化的 `SessionEvent` 信封,以及可通过合并扩展的 `SessionEventMap` 中的每个成员,包括 `@deepseek-ai/dsh-session` 所属的词汇和本仓库中每个插件的声明合并,并附有源 JSDoc、完整 payload 声明、surface 标记和声明位置。本文档是 [session.md](subsystems/session.md)(surface 排序与 `deriveMessages()` 投影)、[persistence.md](subsystems/persistence.md)(如何让日志持久化)和 [session.md](subsystems/session.md#cordis-surface) 中生成区域(实时总线接线;日志事件**不是** cordis 事件,它通过唯一一次 `session/event` emit 到达监听器)的补充。 +会话持久事件日志中可能出现的所有事件类型:完整持久化的 `SessionEvent` 信封,以及可通过合并扩展的 `SessionEventMap` 中的每个成员,包括 `@deepseek-ai/dsh-session` 所属的词汇和本仓库中每个插件对 `@deepseek-ai/dsh-session/types` 的声明合并,并附有源 JSDoc、完整 payload 声明、surface 标记和声明位置。本文档是 [session.md](subsystems/session.md)(surface 排序与 `deriveMessages()` 投影)、[persistence.md](subsystems/persistence.md)(如何让日志持久化)和 [session.md](subsystems/session.md#cordis-surface) 中生成区域(实时总线接线;日志事件**不是** cordis 事件,它通过唯一一次 `session/event` emit 到达监听器)的补充。 英文源文件根据源码生成(`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)。 @@ -103,7 +103,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) ### `approval/*` @@ -214,7 +214,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/commands/src/index.ts:151`](../packages/interaction/commands/src/index.ts) +来源:[`packages/interaction/commands/src/types.ts:41`](../packages/interaction/commands/src/types.ts) #### `command/run` — log-only @@ -232,7 +232,7 @@ export type SessionEvent = { 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -来源:[`packages/interaction/commands/src/index.ts:144`](../packages/interaction/commands/src/index.ts) +来源:[`packages/interaction/commands/src/types.ts:34`](../packages/interaction/commands/src/types.ts) ### `compact/*` @@ -243,10 +243,10 @@ export type SessionEvent = { * Marks the end of a compaction — log-only, releases the lock. Its owner * matches `compact/start`; `error` records an unsuccessful attempt. */ -'compact/end': { turn: number | null; error?: string } +'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } ``` -来源:[`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts) +来源:[`packages/compact/compact/src/types.ts:71`](../packages/compact/compact/src/types.ts) #### `compact/prune` — log-only @@ -270,7 +270,7 @@ export type SessionEvent = { } ``` -来源:[`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts) +来源:[`packages/compact/compact/src/types.ts:81`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -280,10 +280,10 @@ export type SessionEvent = { * `compact/end`. A numbered owner is strictly enclosed by that open turn; * `null` identifies a standalone manual transaction between turns. */ -'compact/start': { turn: number | null } +'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } ``` -来源:[`packages/compact/compact/src/types.ts:19`](../packages/compact/compact/src/types.ts) +来源:[`packages/compact/compact/src/types.ts:23`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -298,6 +298,8 @@ export type SessionEvent = { * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { + compactionId: CompactionId + sourceCommandId?: CommandId summary: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] @@ -333,7 +335,7 @@ export type SessionEvent = { 类型:[ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts) +来源:[`packages/compact/compact/src/types.ts:33`](../packages/compact/compact/src/types.ts) ### `feedback/*` @@ -414,29 +416,19 @@ export type SessionEvent = { ```ts persistence-catalog /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ -'llm/retry': { - turn: number - step: number - provider: string - mode: 'normal' - policyKey: string - retry: number - maxRetries: number - delayMs: number - failure: LlmFailure -} | { - turn: number - step: number - provider: string - mode: 'always' - policyKey: string - retry: number - delayMs: number - failure: LlmFailure -} +'llm/retry': LlmRetryEventData ``` -来源:[`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src/index.ts) +来源:[`packages/llm/llm-retry/src/types.ts:9`](../packages/llm/llm-retry/src/types.ts) + +#### `llm/retry-started` — log-only + +```ts persistence-catalog +/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ +'llm/retry-started': LlmRetryStartedEventData +``` + +来源:[`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts) ### `permission/*` @@ -658,12 +650,10 @@ export type SessionEvent = { * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } +'tool/code-dispatch': CodeDispatchEventData ``` -类型:[CallId](subsystems/core.md) · [ContentBlock](subsystems/core.md) - -来源:[`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/code-mode.ts) +来源:[`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) #### `tool/code-dispatch-start` — log-only @@ -681,12 +671,10 @@ export type SessionEvent = { * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } +'tool/code-dispatch-start': CodeDispatchStartEventData ``` -类型:[CallId](subsystems/core.md) - -来源:[`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/code-mode.ts) +来源:[`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) #### `tool/result` — surface diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 04f3cc5394..4c8cad1880 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: a5162c77d510c5ab66fa2dd6789693e1449970fb -session.zh.md: 989a3f5368aa0b0475dec4272fde724fd4a98230 +session.md: e5eb78908ba4b7ebfc398d5c735969a5a8937374 +session.zh.md: 51729999a831c94e87e36ec1c4705201f9c8360f diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index a5162c77d5..e5eb78908b 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -580,6 +580,8 @@ Activity ordering excludes the boundary through `lastActivityTime(events)`: pick A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The generated [persistence log event catalog](../persistence-catalog.md) enumerates every core and plugin-contributed event with its payload, surface badge, and declaration site; the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). +When several events in one plugin-owned family assemble into one Web Client Conversation Node, every start, update, result, resource, or interruption event in that family carries or independently derives the same stable business id. This requirement applies to correlated Node families, not to every Session event; it lets the client group each event without guessing from adjacency or scanning history. See the [Conversation Node cookbook](../cookbook/adding-a-conversation-node.md). + The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record because it runs before turn 1; its context remains pending in the inbox until a waking delivery opens a turn (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 989a3f5368..51729999a8 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -584,6 +584,8 @@ interface TurnEndReasonMap { 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。生成的[持久化日志事件目录](../persistence-catalog.md)会列出每个核心或插件贡献的事件,以及其 payload、surface 标记和声明位置;压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 +如果同一个插件事件族中的多条事件要组装成一个 Web Client Conversation Node,该事件族中的每条 start、update、result、resource 或 interruption 事件都必须携带或独立推导出同一个稳定业务 id。此要求只约束需要关联的 Node 事件族,并不要求每条 Session 事件都有业务 id;Client 因此无须根据相邻关系猜测归属,也无须扫描历史。参见 [Conversation Node 实操手册](../cookbook/adding-a-conversation-node.md)。 + 钩子桥接层的 `hook/invoked` / `hook/result` 对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录,因为它在轮次 1 之前运行;其上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性约定 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 78704f52fa..9626e96e74 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -54,6 +54,12 @@ Non-negotiables across the layers: - **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`. - **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule). +## Conversation Node discipline + +- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation Node cookbook](../../docs/cookbook/adding-a-conversation-node.md). +- `match(event)` reads only the current event. Every event in a multi-event Context carries or independently derives the same stable business id; `update` folds one Match into State and remains deterministically replayable by log `seq`. +- The append hot path and renderers never scan the full event window, Contexts, or Chat Nodes. Accumulate in State, publish same-Turn/Step facts through `buildLocationData()`, and consume final Node data or constrained Location hooks. + ## Directory regime (plugin packages) One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index d47266cd8a..139474af19 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/client/runtime/README.md -README.md: 46a383f22032c8da7e4bb9b5fb445980661c670f -README.zh.md: 73578a8f249472abfdd7c539e3eaee6de767ad60 +README.md: df3689176cf059f46004fa7ecb31ab7c326ea0bc +README.zh.md: 9afe9773ff2a6a320bdf66978877678428c46118 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 46a383f220..df3689176c 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -38,6 +38,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair. +Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path. + `ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target. The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 73578a8f24..9afe9773ff 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -38,6 +38,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。 +Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id,并保证 update 能按日志 `seq` 回放;renderer 只消费最终 Node data 与受限 Location value,不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。 + `ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。 Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bb15c65029..1238e136d7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 3570e814609af0bc4e7d048a55205af756fae843 -README.zh.md: e37f867c72d83b47924e87261e6ee5ff8b374a6f +README.md: d894d3e578f065e9cb2c45676224efd1f4f76fa8 +README.zh.md: ec99a8d7daa593cdf3014f292a095fb2fd6f39d3 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3570e81460..d894d3e578 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,6 +12,8 @@ Another plugin can make one session's composer inert through `ctx.conversation.b The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. +Chat business rows are independent registry contributions rather than a closed built-in union. A client plugin declaration-merges its typed `ChatNodeDataMap` key, registers a `ConversationNodeDefinition` on `ctx.conversationEvents`, and registers the matching keyed renderer on `conversation.chat.node`; it does not modify Session folds or a central renderer switch. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) covers stable event ids, append/prepend replay, Location data, and renderer constraints. + Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e37f867c72..ec99a8d7da 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,6 +12,8 @@ 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 +Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。Client 插件通过 declaration merging 增加类型化 `ChatNodeDataMap` key,在 `ctx.conversationEvents` 上注册 `ConversationNodeDefinition`,再向 `conversation.chat.node` 注册匹配的 keyed renderer;它无须修改 Session fold 或中央 renderer switch。稳定事件 id、append/prepend 回放、Location data 与 renderer 约束见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。 + 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index b5c000a714..2626cec2da 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1782, "docs/AGENTS.md": 1320, - "docs/architecture.md": 2160, + "docs/architecture.md": 2174, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1150, diff --git a/website/docs.ts b/website/docs.ts index 1e97d88e24..a8d07a59c2 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -396,6 +396,14 @@ const reference = [ section: { root: '开发手册', en: 'Cookbook' }, order, }))), + ...pairedPages([{ + source: 'docs/cookbook/adding-a-conversation-node.md', + route: 'reference/cookbook/adding-a-conversation-node.md', + label: { root: '新增 Conversation Node', en: 'Adding a Conversation Node' }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '开发手册', en: 'Cookbook' }, + order: 4, + }]), ] /** Every canonical page published by the documentation website. */ From fcbc97a88dd7f22aa5f71342d19248485e7cc58f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:08:37 +0800 Subject: [PATCH 18/32] fix(client): address conversation assembly review --- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/conversation-nodes/inbox.ts | 4 +- .../src/client/conversation-nodes/retry.ts | 1 + .../conversation-node-definitions.spec.ts | 32 ++++++++++++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../tests/produced-files.spec.tsx | 52 ++++++++++++++++++- packages/compact/compact/src/types.ts | 4 +- packages/llm/llm-retry/src/types.ts | 4 +- .../llm/llm-retry/tests/invariant.spec.ts | 2 +- .../llm/llm-retry/tests/persistence.spec.ts | 2 +- .../context-breakdown-projection.spec.ts | 2 +- .../tests/token-usage-projection.spec.ts | 2 +- .../llm-replay/tests/llm-replay.spec.ts | 2 +- tsconfig.base.json | 1 + vitest.config.ts | 1 - 19 files changed, 102 insertions(+), 23 deletions(-) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 139474af19..734b158e66 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/client/runtime/README.md -README.md: df3689176cf059f46004fa7ecb31ab7c326ea0bc -README.zh.md: 9afe9773ff2a6a320bdf66978877678428c46118 +README.md: d4031c2e3b7730bdb0075ac84c50ad3e46ce63a2 +README.zh.md: ca6ac9f5e8efec3472c03b044739a9221e98183c diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index df3689176c..d4031c2e3b 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -58,7 +58,7 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start ## Model retry projection -The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted assistant node beside the terminal error. +The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed step's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error. ## Session forking diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9afe9773ff..ca6ac9f5e8 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -58,7 +58,7 @@ Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新 ## 模型重试投影 -Session 对象会在事件 wire 边界依据生产方的完整字段约定,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。 +Host 所属的 LLM retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry` 与 `llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败步骤的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`;如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`,started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。 ## 会话 fork diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts index 74406c324a..e3cfae47f4 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -2,9 +2,7 @@ import type { Context } from 'cordis' import type { ConversationNodeDefinition, ConversationPreviousContext, } from '@deepseek-ai/dsh-client-runtime/client' -import type {} from '@deepseek-ai/dsh-agent/types' - -type InboxTarget = 'next-turn' | 'next-step' +import type { InboxTarget } from '@deepseek-ai/dsh-agent/types' interface InboxIdentity { readonly id: string diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index f383bc46d8..fa7c2ee5cf 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -31,6 +31,7 @@ function scheduledNode(match: Parameters[1] } } +/** A scheduled attempt is cancelled once either owning boundary closes. */ function isClosed(location: ConversationLocation): boolean { return (location.kind === 'step' && location.step.status === 'closed') || ((location.kind === 'step' || location.kind === 'turn') && location.turn.status === 'closed') diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index 3e5dcccff3..d90093b864 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -465,6 +465,38 @@ describe('built-in conversation node Definitions', () => { expect(node(snapshot(value), 'user')).toBeUndefined() }) + it('orders claimed steering after the finalized Turn tail', () => { + const steering = textMessage('steer-after-answer', 'change direction') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-before-steering', 'initial answer'), + }, { surfaceOp: 'append' }), + at(4, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + inserted: [steering], + }), + at(5, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + removedCount: 1, + inserted: [], + }), + at(6, 'user/message', steering, { surfaceOp: 'append' }), + at(7, 'step/end', { turn: 1, step: 1 }), + at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + + const current = snapshot(value) + const steeringNode = node(current, 'steering') + expect(steeringNode).toBeDefined() + expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key) + }) + it('classifies appended producer context from durable source metadata', () => { const value = assembler([ at(1, 'user/message', { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index e9810d98da..0df3ca0d86 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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/client/ui-deliverables/README.md -README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2 -README.zh.md: ba493549bd0bc3f8a2adbda5989448e497f0af93 +README.md: 7d03e5faedda3ba8c9cc4cab6ca134d98dc7ec13 +README.zh.md: dfbbc7a39aa94aab438119a4f23ffb02da2daa3d diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 189dedd88f..7d03e5faed 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Produced-files feature owner: registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole. All policy lives here; removing this plugin's line from cordis.yml removes the surface entirely, and the owning view renders an empty hole at zero cost. -`producedForClosing` derives one turn's produced files from the tail hole's owner currency — the finalized snapshot nodes and the closing assistant's seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. +`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row. `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index ba493549bd..dfbbc7a39a 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -4,7 +4,7 @@ 产出文件功能的属主:把已完成轮次末尾的产出文件行注册到 chat 视图的 `conversation.chat.turnTail` slot 中。全部策略都在本包内;从 cordis.yml 中删去本插件那一行即可整体移除该界面,属主视图无需额外开销即可渲染空 slot。 -`producedForClosing` 根据 tail slot 属主提供的当前数据,即定稿快照节点和收尾助手的 seq,推导一个轮次产出的文件。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在轮次边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。 +`deliverablesDefinition` 把每个 Turn 中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个 Turn 内按首见顺序只出现一次。Conversation Location 索引拥有 Turn 成员关系,因此一个 Turn 即使先修改文件、随后没有正文内容就结束,也不会溢进下一个 Turn 的行里。 `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签、至多六个标签项(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index b8e7cd111e..91b15cf361 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** * ui-deliverables browser half: the derivation contract of - * `producedForClosing` over finalized snapshot nodes, the row's rendering + * `producedForClosing` over engine-published Turn data, the row's rendering * and opener wiring, and the plugin registrations' fiber-teardown removal * (HMR safety) against the real SlotsService. */ @@ -12,7 +12,7 @@ import { ConversationEventRegistry, ConversationNodeAssembler, SlotsService, } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationEventInput, ConversationLocationDataStore, ConversationNodeDefinition, + ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, ConversationViewNode, ToolResultNode, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' @@ -107,6 +107,10 @@ function at( } } +function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch { + return { ...input, role, location: { kind: 'unresolved' } } +} + function call( seq: number, callId: string, @@ -190,6 +194,50 @@ describe('produced-file Turn data', () => { ]) }) + it('ignores calls without mutation locations, orphan results, and replacement results', () => { + const replacement = result(8, 'replacement') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }), + result(3, 'no-view'), + call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }), + result(5, 'locationless-edit'), + result(6, 'orphan'), + call(7, 'replacement', diff('replaced.txt')), + { + ...replacement, + event: { + ...replacement.event, + surfaceOp: { op: 'replace', start: 1, end: 1 }, + } as ConversationEventInput['event'], + }, + at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + + expect(producedForClosing(deliverablesOf(value))).toEqual([]) + }) + + it('rejects an invalid start match and preserves state for an unrelated update', () => { + const startMatch = matched(at(1, 'turn/start', { turn: 1 }), 'start') + const emptyContext: Parameters[0] = { + key: 'deliverables:1', + kind: 'deliverables', + id: '1', + matches: [startMatch], + start: startMatch, + state: undefined, + current: new Map(), + } + const reader: Parameters[2] = { previous: () => undefined } + const state = deliverablesDefinition.start(emptyContext, startMatch, reader) + const unrelated = matched(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), 'update') + const context: Parameters[0] = { ...emptyContext, state } + + expect(() => deliverablesDefinition.start(emptyContext, unrelated, reader)) + .toThrow('deliverables start requires turn/start') + expect(deliverablesDefinition.update(context, unrelated)).toBe(state) + }) + it('replays a tail page once prepend supplies its missing Turn start', () => { const value = assembler([ call(10, 'late', diff('history.txt')), diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 53c3baf05f..2cee6c191a 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -9,9 +9,9 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import { CompactionId } from './brand.ts' +import type { CompactionId } from './brand.ts' -export { CompactionId } +export type { CompactionId } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts index 5144939289..e78026cbeb 100644 --- a/packages/llm/llm-retry/src/types.ts +++ b/packages/llm/llm-retry/src/types.ts @@ -1,7 +1,7 @@ import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' -import { RetryId } from './brand.ts' +import type { RetryId } from './brand.ts' -export { RetryId } +export type { RetryId } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 917a6d03f8..dd22e11369 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -5,7 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' -import { RetryId } from '@deepseek-ai/dsh-llm-retry/types' +import { RetryId } from '@deepseek-ai/dsh-llm-retry' import { providerForOpenStep } from '../src/history.ts' async function setup(): Promise { diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 9e17290b43..8affb9e059 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' -import { RetryId } from '@deepseek-ai/dsh-llm-retry/types' +import { RetryId } from '@deepseek-ai/dsh-llm-retry' import type {} from '../src/index.ts' const dirs: string[] = [] diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index bfbf6a89ee..9bdd1222f0 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -10,7 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' -import { CompactionId } from '@deepseek-ai/dsh-compact/types' +import { CompactionId } from '@deepseek-ai/dsh-compact' import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts' import { estimateContent, diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 3f66e0564b..9ccc2d7e9b 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -7,7 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' -import { CompactionId } from '@deepseek-ai/dsh-compact/types' +import { CompactionId } from '@deepseek-ai/dsh-compact' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index db60b695b1..f9aa66bce5 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { CompactionId } from '@deepseek-ai/dsh-compact/types' +import { CompactionId } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, diff --git a/tsconfig.base.json b/tsconfig.base.json index c7be2d4925..e404e3fc8b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -79,6 +79,7 @@ "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/interaction/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/interaction/user-interaction/src/types.ts"], + "@deepseek-ai/dsh-agent/types": ["./packages/core/agent/src/types.ts"], "@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index 33512f4898..e07ae43ec3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -211,7 +211,6 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/index.ts', 'packages/client/test-runtime/src/translate.ts', 'packages/client/ui-primitives/src/JsonTree.tsx', - 'packages/client/ui-deliverables/src/client/turn-deliverables.ts', // Typert generator: correctness is pinned by its fixture suites and // the byte-for-byte catalog reproduction test; per-file coverage // would put whole-workspace compiler analysis under v8 From 0cfe852c2d4f0893a7d77326995513916c79a872 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:21:49 +0800 Subject: [PATCH 19/32] fix(ui-conversation): ignore legacy compact events without ids --- .../client/conversation-nodes/compaction.ts | 4 +++- .../conversation-node-definitions.spec.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts index 04eace8038..d21b4aa4b4 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -39,7 +39,9 @@ export const compactionDefinition: ConversationNodeDefinition = || event.type === 'compact/summary' || event.type === 'compact/end') { if (event.data.sourceCommandId !== undefined) return null - return { id: String(event.data.compactionId), role: event.type === 'compact/start' ? 'start' : 'update' } + const compactionId: unknown = event.data.compactionId + if (typeof compactionId !== 'string' || compactionId === '') return null + return { id: compactionId, role: event.type === 'compact/start' ? 'start' : 'update' } } return null }, diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index d90093b864..016c7176db 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -698,6 +698,26 @@ describe('built-in conversation node Definitions', () => { }) }) + it('ignores legacy compaction transactions without correlation ids', () => { + const value = assembler([ + at(10, 'compact/start', { turn: null }), + at(11, 'compact/end', { turn: null, error: 'This operation was aborted' }), + at(20, 'compact/start', { turn: null }), + at(21, 'compact/summary', { + summary: [{ type: 'text', text: 'legacy summary' }], + shadowedSeqs: [1, 2, 3], + shadowedTokenCount: 42, + }), + at(22, 'user/message', { + ...textMessage('legacy-checkpoint', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }), + at(23, 'compact/end', { turn: null }), + ], true) + + expect(node(snapshot(value), 'compaction')).toBeUndefined() + }) + it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => { const value = assembler([ at(5, 'llm/retry', { From 81822f40762806c354f2abd489e04841653e507a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:23:58 +0800 Subject: [PATCH 20/32] fix(ui-conversation): skip uncorrelated legacy events --- .../src/client/conversation-nodes/retry.ts | 7 ++- .../src/client/conversation-nodes/tool.ts | 5 ++- .../conversation-node-definitions.spec.ts | 44 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index fa7c2ee5cf..d504f32928 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -42,10 +42,13 @@ export const retryDefinition: ConversationNodeDefinition = { kind: 'model-retry', match: (event) => { if (event.type === 'llm/retry') { - return { id: String(event.data.retryId), role: event.data.retry === 1 ? 'start' : 'update' } + const retryId: unknown = event.data.retryId + if (typeof retryId !== 'string' || retryId === '') return null + return { id: retryId, role: event.data.retry === 1 ? 'start' : 'update' } } if (event.type === 'llm/retry-started') { - return { id: String(event.data.retryId), role: 'update' } + const retryId: unknown = event.data.retryId + return typeof retryId === 'string' && retryId !== '' ? { id: retryId, role: 'update' } : null } return null }, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts index dc9e996dcc..c1b8022e41 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -241,7 +241,10 @@ export const toolDefinition: ConversationNodeDefinition = { return { id: String(event.data.message.source.callId), role: 'update' } } if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { - return { id: String(event.data.rootCallId), role: 'update' } + const rootCallId: unknown = event.data.rootCallId + return typeof rootCallId === 'string' && rootCallId !== '' + ? { id: rootCallId, role: 'update' } + : null } return null }, diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index 016c7176db..db9c0ccdd4 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -718,6 +718,50 @@ describe('built-in conversation node Definitions', () => { expect(node(snapshot(value), 'compaction')).toBeUndefined() }) + it('ignores legacy retry and code-dispatch events without correlation ids', () => { + const value = assembler([ + at(10, 'llm/retry', { + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'first legacy retry' }, + }), + at(11, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }), + at(20, 'llm/retry', { + turn: 2, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'second legacy retry' }, + }), + at(30, 'tool/code-dispatch-start', { + parentCallId: 'root', + subCallId: 'child', + name: 'legacy-subcall', + arguments: {}, + }), + at(31, 'tool/code-dispatch', { + parentCallId: 'root', + subCallId: 'child', + name: 'legacy-subcall', + arguments: {}, + content: [], + }), + ], true) + + expect(node(snapshot(value), 'model-retry')).toBeUndefined() + expect(node(snapshot(value), 'tool-call')).toBeUndefined() + }) + it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => { const value = assembler([ at(5, 'llm/retry', { From d99ad2b1d05da476e93b5eaa4a58e288d4d8b179 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:36:57 +0800 Subject: [PATCH 21/32] fix conversation node review follow-ups --- docs/subsystems/compaction.i18n.yaml | 4 ++-- docs/subsystems/compaction.md | 7 +++--- docs/subsystems/compaction.zh.md | 7 +++--- .../fixtures/workspace-context-compaction.ts | 4 ++-- packages/client/runtime/package.json | 1 + .../src/client/sessions/steering-history.ts | 4 +--- .../src/client/conversation-nodes/command.ts | 4 ++-- packages/compact/compact/README.i18n.yaml | 4 ++-- packages/compact/compact/README.md | 10 ++++---- packages/compact/compact/README.zh.md | 10 ++++---- packages/compact/compact/src/checkpoint.ts | 23 +++++++++---------- packages/compact/compact/src/index.ts | 13 ++++++----- .../tests/session-reference.spec.ts | 15 ++++++++---- packages/llm/llm-retry/README.i18n.yaml | 4 ++-- packages/llm/llm-retry/README.md | 4 ++-- packages/llm/llm-retry/README.zh.md | 4 ++-- .../tool-cordis/src/api-catalog.ts | 2 +- pnpm-lock.yaml | 3 +++ 18 files changed, 67 insertions(+), 56 deletions(-) diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index 532750f069..5e7c6d92be 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.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/compaction.md -compaction.md: 11ee6eae797db1c57bf2ffbb90f1f37f47b8400c -compaction.zh.md: a560e47dbb7aa92f0757179534803bbcaaebc6ba +compaction.md: 8325694d92aa1a3019aef8f6b0c1f99d45ad0df5 +compaction.zh.md: 9f9014b8c1c1db12abc6581fbe6ebacfac3dd6fe diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 11ee6eae79..8325694d92 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -66,7 +66,7 @@ Automatic callers state why policy is running; implementations may treat confirm type CompactionTrigger = 'pressure' | 'context-overflow' ``` -`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, `compactNow(agent, signal)` for one useful idle-session reduction even below pressure, and `compactRegion(...)` for an explicit inclusive surface range. `compactNow()` runs as agent maintenance between turns, returns `null` without writing when no useful range exists, records a standalone `turn: null` bracket before summarization, and flushes a closed attempt before later queued prompts may derive from the new surface. Every backend marks its replacement `user/message` with `COMPACT_CHECKPOINT_SOURCE`; client and wire consumers import that value and `isCompactCheckpointSource()` from the cordis-free `@deepseek-ai/dsh-compact/checkpoint` subpath, while the package root re-exports both for host consumers. The predicate keeps checkpoint recognition independent of any one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. +`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, `compactNow(agent, signal)` for one useful idle-session reduction even below pressure, and `compactRegion(...)` for an explicit inclusive surface range. `compactNow()` runs as agent maintenance between turns, returns `null` without writing when no useful range exists, records a standalone `turn: null` bracket before summarization, and flushes a closed attempt before later queued prompts may derive from the new surface. Every backend creates its replacement `user/message` source with `compactCheckpointSource(compactionId, sourceCommandId?)`; client and wire consumers import that constructor, `CompactCheckpointSource`, and `isCompactCheckpointSource()` from the cordis-free `@deepseek-ai/dsh-compact/checkpoint` subpath, while the package root re-exports them for host consumers. The required transaction identity correlates the replacement checkpoint, while the predicate keeps recognition independent of any one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. Expected manual failures use `ManualCompactionErrorCode`: @@ -129,7 +129,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.compact` — `CompactService` (abstract seam) -Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses COMPACT_CHECKPOINT_SOURCE so consumers recognize it independently of the backend. Load one implementation per context as `ctx.compact`. +Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses compactCheckpointSource with the transaction identity so consumers recognize and correlate it independently of the backend. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog /** @@ -175,7 +175,8 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sour * balanced so assistant tool calls remain paired with their results. A model- * backed implementation forwards cancellation and rejects active, missing, * reversed, or unbalanced ranges. The target session is `agent.session`. - * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}. + * Its replacement user message must use {@link compactCheckpointSource} with + * the transaction's `CompactionId`. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index a560e47dbb..9f9014b8c1 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -66,7 +66,7 @@ interface CompactionResult { type CompactionTrigger = 'pressure' | 'context-overflow' ``` -`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略,暴露 `compactNow(agent, signal)` 以便即使未达到压力也对空闲会话进行一次有效缩减,还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。`compactNow()` 作为轮次之间的 agent maintenance 运行;没有有效范围时返回 `null` 且不写入;在摘要前记录独立的 `turn: null` 标记对,并在后续排队提示词能够从新表层派生前 flush 已闭合尝试。每个后端都使用 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;client 与 wire 消费方从无 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 子路径导入该值和 `isCompactCheckpointSource()`,包根则为 host 消费方重新导出两者。该判定函数使检查点识别不依赖任一特定后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 +`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略,暴露 `compactNow(agent, signal)` 以便即使未达到压力也对空闲会话进行一次有效缩减,还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。`compactNow()` 作为轮次之间的 agent maintenance 运行;没有有效范围时返回 `null` 且不写入;在摘要前记录独立的 `turn: null` 标记对,并在后续排队提示词能够从新表层派生前 flush 已闭合尝试。每个后端都使用 `compactCheckpointSource(compactionId, sourceCommandId?)` 创建替换用 `user/message` 的源;client 与 wire 消费方从无 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 子路径导入该构造函数、`CompactCheckpointSource` 和 `isCompactCheckpointSource()`,包根则为 host 消费方重新导出它们。必填的事务身份会关联替换检查点,而该判定函数使检查点识别不依赖任一特定后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 预期的手动失败使用 `ManualCompactionErrorCode`: @@ -129,7 +129,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.compact` — `CompactService` (abstract seam) -Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses COMPACT_CHECKPOINT_SOURCE so consumers recognize it independently of the backend. Load one implementation per context as `ctx.compact`. +Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses compactCheckpointSource with the transaction identity so consumers recognize and correlate it independently of the backend. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog /** @@ -175,7 +175,8 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sour * balanced so assistant tool calls remain paired with their results. A model- * backed implementation forwards cancellation and rejects active, missing, * reversed, or unbalanced ranges. The target session is `agent.session`. - * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}. + * Its replacement user message must use {@link compactCheckpointSource} with + * the transaction's `CompactionId`. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * diff --git a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts index e13e3cf6b3..6c1cd46fbf 100644 --- a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts +++ b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts @@ -1,6 +1,6 @@ import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-agent' -import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact' +import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-tools' @@ -26,7 +26,7 @@ export function apply(ctx: Context): void { if (baseline === undefined) throw new Error('workspace baseline missing before snapshot compaction') agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Earlier context was compacted for this snapshot.' }], - source: COMPACT_CHECKPOINT_SOURCE, + source: compactCheckpointSource(CompactionId('workspace-context-fixture')), }), { surfaceOp: { op: 'replace', start: baseline.seq, end: baseline.seq }, sourceEventSeqs: [baseline.seq], diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index f5a9a06a60..83a62693cb 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/client/runtime/src/client/sessions/steering-history.ts b/packages/client/runtime/src/client/sessions/steering-history.ts index 4220a769ff..a28c3376a8 100644 --- a/packages/client/runtime/src/client/sessions/steering-history.ts +++ b/packages/client/runtime/src/client/sessions/steering-history.ts @@ -1,9 +1,7 @@ /** Reconstruct durable steering identity from the event-sourced agent inbox. */ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-agent/types' - -type InboxTarget = 'next-turn' | 'next-step' +import type { InboxTarget } from '@deepseek-ai/dsh-agent/types' /** Minimal pending identity retained while replaying durable inbox splices. */ interface PendingIdentity { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts index 38cb85ed87..752d7e3dd2 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -4,7 +4,7 @@ import type { ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-runtime/client' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint' import type {} from '@deepseek-ai/dsh-compact/types' import type {} from '@deepseek-ai/dsh-commands/types' import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' @@ -21,7 +21,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { type CommandId = CommandNode['commandId'] -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' interface CommandState { readonly command: CommandNode diff --git a/packages/compact/compact/README.i18n.yaml b/packages/compact/compact/README.i18n.yaml index ec95ee2165..8b16ecfa4b 100644 --- a/packages/compact/compact/README.i18n.yaml +++ b/packages/compact/compact/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/compact/compact/README.md -README.md: 3fecc91422bcdc994bcbe4db308ee4a52cb3c53f -README.zh.md: ea66d8bdb0684f27cf68a5431be75966041b7534 +README.md: eac4339da76352bc9468488d0828a0b90a993d48 +README.zh.md: 4aec6123169a7c88a36da647202e7462fbf94564 diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 3fecc91422..eac4339da7 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -8,7 +8,7 @@ This package owns the Service Definition role of the compaction capability, spli | Package | Role | |---|---| -| `@deepseek-ai/dsh-compact` (this) | Service Definition: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers | +| `@deepseek-ai/dsh-compact` (this) | Service Definition: abstract service + `compact/*` events + `CompactionResult` + correlated checkpoint-source constructor + tool-pairing boundary helpers | | `@deepseek-ai/dsh-compact-basic` | Service provider: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-command-compact` | Consumer: the human `/compact` command over `ctx.compact.compactNow()` | @@ -22,7 +22,7 @@ All three operations are **abstract** — the backend owns trigger policy, reten |---|---| | `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | | `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compact/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. | -| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source is `COMPACT_CHECKPOINT_SOURCE`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | +| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source comes from `compactCheckpointSource(compactionId)`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | `CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/subsystems/compaction.md#compactionresult). @@ -43,7 +43,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an 1. appends `compact/start` (log-only) — acquires the lock, 2. summarizes the range, 3. appends `compact/summary` (log-only) with the summary, range, shadowed seqs, token count, and provider/model call envelope, -4. appends a single `user/message` with `source: COMPACT_CHECKPOINT_SOURCE` and `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**, +4. appends a single `user/message` with `source: compactCheckpointSource(compactionId, sourceCommandId?)` and `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**, 5. appends `compact/end` (log-only) — releases the lock. The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed. @@ -64,11 +64,11 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati ## Implementing a backend -Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter. +Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend creates its replacement user message source with `compactCheckpointSource(compactionId, sourceCommandId?)`; the required `compactionId` correlates the checkpoint with its `compact/*` transaction, while `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter. ## Recognizing a checkpoint outside the host program (`./checkpoint`) -`COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../interaction/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to this leaf with a type-only import, so renaming the plugin id here is a compile error there. +`compactCheckpointSource()`, `CompactCheckpointSource`, and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The constructor requires the owning `CompactionId`, preventing backends from writing an uncorrelated marker that the package invariant must reject. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../interaction/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to the leaf's source type, so renaming the plugin id there is a compile error here. ## Model Experience diff --git a/packages/compact/compact/README.zh.md b/packages/compact/compact/README.zh.md index ea66d8bdb0..4aec612316 100644 --- a/packages/compact/compact/README.zh.md +++ b/packages/compact/compact/README.zh.md @@ -8,7 +8,7 @@ | 包 | 职责 | |---|---| -| `@deepseek-ai/dsh-compact`(本包) | Service Definition:抽象服务 + `compact/*` 事件 + `CompactionResult` + 规范检查点源 + 工具配对边界 helper | +| `@deepseek-ai/dsh-compact`(本包) | Service Definition:抽象服务 + `compact/*` 事件 + `CompactionResult` + 关联检查点源构造函数 + 工具配对边界 helper | | `@deepseek-ai/dsh-compact-basic` | Service provider:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 | | `@deepseek-ai/dsh-command-compact` | Consumer:面向人类的 `/compact` 命令,基于 `ctx.compact.compactNow()` 实现 | @@ -22,7 +22,7 @@ |---|---| | `compactIfNeeded(agent, trigger, signal)` | 根据 `trigger: 'pressure' \| 'context-overflow'` 判断是否需要自动压缩。压力触发可应用后端的阈值与保留尾部策略;已确认溢出可强制进行有效的平衡缩减。返回 `CompactionResult`,无安全范围时则返回 `null`。后端摘要请求是直接的 `ctx.llm.stream()` 调用(不是 agent loop 步骤),因此每次调用都可在 `llm/stream` 处拦截。 | | `compactNow(agent, signal)` | 即使未达到自动压力,也显式压缩一段有效、平衡的较早范围。该操作会在让出控制权前同步预留空闲轮次接纳;没有有效范围时不写入任何内容;在摘要前记录独立的 `compact/* { turn: null }` 尝试;释放预留前等待其持久性检查点。预期操作失败使用 `ManualCompactionError`;取消会原样重新抛出 abort 原因。 | -| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源为 `COMPACT_CHECKPOINT_SOURCE`。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 | +| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源由 `compactCheckpointSource(compactionId)` 创建。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 | `CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq,同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/subsystems/compaction.md#compactionresult)。 @@ -43,7 +43,7 @@ 1. 追加 `compact/start`(仅日志):获取锁; 2. 摘要该范围; 3. 追加 `compact/summary`(仅日志),其中记录摘要、范围、已遮蔽 seq、token 数与提供方/模型调用 envelope; -4. 追加单个 `user/message`,其携带 `source: COMPACT_CHECKPOINT_SOURCE` 和包含摘要的 `surfaceOp: { op: 'replace', start, end }`:这是**本操作唯一的表层变更**; +4. 追加单个 `user/message`,其携带 `source: compactCheckpointSource(compactionId, sourceCommandId?)` 和包含摘要的 `surfaceOp: { op: 'replace', start, end }`:这是**本操作唯一的表层变更**; 5. 追加 `compact/end`(仅日志):释放锁。 表层变更(第 4 步)位于锁的起止范围**内**:`compact/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compact/start` 与 `compact/end` 之间崩溃,会留下可检测的遗留锁(一个 `compact/start` 没有匹配的 `compact/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compact/end`。 @@ -64,11 +64,11 @@ ## 实现后端 -继承 `CompactService`,实现 `compactIfNeeded`、`compactNow` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。 +继承 `CompactService`,实现 `compactIfNeeded`、`compactNow` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都使用 `compactCheckpointSource(compactionId, sourceCommandId?)` 创建替换 user 消息的源;必填的 `compactionId` 将检查点与对应 `compact/*` 事务关联,而 `isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。 ## 在 host 程序之外识别检查点(`./checkpoint`) -`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。该叶子不导入 cordis、也不声明任何模块增强(即 [`dsh-commands/brand`](../../interaction/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout))。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子上,因此在此处改插件 id 会让那边编译失败。 +`compactCheckpointSource()`、`CompactCheckpointSource` 与 `isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。构造函数要求传入所属 `CompactionId`,防止后端写入缺少关联关系、必然被包不变量拒绝的标记。该叶子不导入 cordis、也不声明任何模块增强(即 [`dsh-commands/brand`](../../interaction/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout))。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子的源类型上,因此在此处改插件 id 会让那边编译失败。 ## 模型体验 diff --git a/packages/compact/compact/src/checkpoint.ts b/packages/compact/compact/src/checkpoint.ts index 9908fe4ac0..18a2c5985e 100644 --- a/packages/compact/compact/src/checkpoint.ts +++ b/packages/compact/compact/src/checkpoint.ts @@ -1,12 +1,12 @@ /** - * The compaction seam's canonical checkpoint source: the plugin marker every - * backend stamps on the replacement user message that lands a checkpoint, plus - * the predicate that recognizes it. + * Compaction checkpoint provenance: the correlated source constructor and type + * every backend uses for its replacement user message, plus the predicate that + * recognizes persisted checkpoints. * - * The seam itself lives in `@deepseek-ai/dsh-compact`, which re-exports both of - * these; this module is a pure value/predicate outlet (no cordis imports, no - * module augmentation) so client and wire programs can name the checkpoint - * source without loading the host plugin's Context merges — the + * The seam itself lives in `@deepseek-ai/dsh-compact`, which re-exports these + * contracts; this module is a pure type/value/predicate outlet (no cordis + * imports, no module augmentation) so client and wire programs can name the + * checkpoint source without loading the host plugin's Context merges — the * `dsh-commands/brand` shape. * * @module @deepseek-ai/dsh-compact/checkpoint @@ -16,11 +16,10 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm/message' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CompactionId } from './brand.ts' -/** Canonical source for the replacement user message produced by every compaction backend. */ -export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const) +const COMPACT_CHECKPOINT_MARKER = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const) /** Message provenance carried by a concrete compaction checkpoint. */ -export type CompactCheckpointSource = typeof COMPACT_CHECKPOINT_SOURCE & { +export type CompactCheckpointSource = typeof COMPACT_CHECKPOINT_MARKER & { readonly compactionId: CompactionId readonly sourceCommandId?: CommandId } @@ -36,7 +35,7 @@ export function compactCheckpointSource( sourceCommandId?: CommandId, ): CompactCheckpointSource { return Object.freeze({ - ...COMPACT_CHECKPOINT_SOURCE, + ...COMPACT_CHECKPOINT_MARKER, compactionId, ...sourceCommandId === undefined ? {} : { sourceCommandId }, }) @@ -48,5 +47,5 @@ export function compactCheckpointSource( * @returns whether the source carries the backend-independent checkpoint marker. */ export function isCompactCheckpointSource(source: MessageSource): boolean { - return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin + return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_MARKER.plugin } diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 0085321333..6fb9706c04 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -15,10 +15,10 @@ import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' export { CompactionId } from './brand.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' -// The checkpoint source and its predicate are declared on the cordis-free +// The checkpoint source constructor and predicate are declared on the cordis-free // `./checkpoint` leaf so client and wire programs can name them without this // root's Context merge; the root stays the host-side entry point for both. -export { COMPACT_CHECKPOINT_SOURCE, compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts' +export { compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts' export type { CompactCheckpointSource } from './checkpoint.ts' /** Why automatic policy is asking a backend to consider compaction. */ @@ -89,9 +89,9 @@ declare module 'cordis' { * and summarization, and may consume a separate measurement service. A * successful run replaces the selected surface span with one summary node and * prevents concurrent compaction of the same session. The replacement user - * message uses {@link COMPACT_CHECKPOINT_SOURCE} so consumers recognize it - * independently of the backend. Load one implementation per context as - * `ctx.compact`. + * message uses {@link compactCheckpointSource} with the transaction identity + * so consumers recognize and correlate it independently of the backend. Load + * one implementation per context as `ctx.compact`. */ export abstract class CompactService extends Service { constructor(ctx: Context) { @@ -149,7 +149,8 @@ export abstract class CompactService extends Service { * balanced so assistant tool calls remain paired with their results. A model- * backed implementation forwards cancellation and rejects active, missing, * reversed, or unbalanced ranges. The target session is `agent.session`. - * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}. + * Its replacement user message must use {@link compactCheckpointSource} with + * the transaction's `CompactionId`. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index b9e5e55241..ce964af16a 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact' +import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionQueryService from '@deepseek-ai/dsh-session-query' @@ -48,6 +48,10 @@ function expectCode(code: SessionReferenceErrorCode): Error { return expect.objectContaining({ code }) as Error } +function checkpointSource(id: string) { + return compactCheckpointSource(CompactionId(id)) +} + function appendConversation(session: Session): void { const oldUser = session.append( 'user/message', @@ -75,7 +79,8 @@ function appendConversation(session: Session): void { session.append( 'user/message', createUserMessage({ - content: [{ type: 'text', text: 'checkpoint' }], source: COMPACT_CHECKPOINT_SOURCE, + content: [{ type: 'text', text: 'checkpoint' }], + source: checkpointSource('conversation'), }), { surfaceOp: { op: 'replace', start: oldUser.seq, end: oldAssistant.seq }, @@ -534,7 +539,8 @@ describe('session reference discovery and preparation', () => { source.append( 'user/message', createUserMessage({ - content: [{ type: 'text', text: `${id}-${'界'.repeat(400)}` }], source: COMPACT_CHECKPOINT_SOURCE, + content: [{ type: 'text', text: `${id}-${'界'.repeat(400)}` }], + source: checkpointSource(id), }), { surfaceOp: 'append' }, ) @@ -616,7 +622,8 @@ describe('session reference discovery and preparation', () => { source.append( 'user/message', createUserMessage({ - content: [{ type: 'text', text: 'later compact checkpoint' }], source: COMPACT_CHECKPOINT_SOURCE, + content: [{ type: 'text', text: 'later compact checkpoint' }], + source: checkpointSource('later-source-mutation'), }), { surfaceOp: { op: 'replace', start: original.seq, end: later.seq }, diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index 65e0c911ed..2a1f8d46a2 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/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/llm/llm-retry/README.md -README.md: e6e56ec44032d714393c6fcc1c42d7271017a294 -README.zh.md: b7ce8bee4acd2c4f7c88870745dff96ec5695435 +README.md: 0a907b5505650f7c2cd5e9933750be3701f8e34b +README.zh.md: bb8c5b50efddf3def0047f411285b57c3c544f95 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index e6e56ec440..0a907b5505 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -8,9 +8,9 @@ Each provider adapter owns an optional nested `retryPolicy`, captured when its r Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. -Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. +Before waiting, the plugin appends a non-surface `llm/retry` event with the shared `retryId`, provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. When the wait completes, the plugin appends `llm/retry-started` with the same `retryId`, turn, step, and retry number immediately before returning `{ kind: 'retry' }`; cancellation during backoff writes no started event. The loop then closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. -The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. +The separately published `./invariant` companion checks that every scheduled retry names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. It also requires each `llm/retry-started` event to name one prior scheduled attempt with the same `retryId`, turn, step, and retry number, and rejects repeated started events. Full jitter may schedule zero milliseconds at its lower boundary. ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index b7ce8bee4a..bb8c5b50ef 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -8,9 +8,9 @@ 两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。 -等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。 +等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含共享 `retryId`、提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待完成时,插件会在返回 `{ kind: 'retry' }` 前立即追加 `llm/retry-started`,其中带有相同的 `retryId`、轮次、步骤与重试编号;退避期间取消则不会写入 started 事件。随后循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。 -单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。 +单独发布的 `./invariant` 配套模块会检查每个已调度重试是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。它还要求每个 `llm/retry-started` 事件通过相同的 `retryId`、轮次、步骤与重试编号指向一个先前调度的尝试,并拒绝重复的 started 事件。完整 jitter 可以在下界调度为零毫秒。 ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index b52000f554..c8ec4894b4 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -282,7 +282,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', - jsDoc: '/**\n * Forcibly compact a range of surface nodes into a single summary node.\n * `start` and `end` name an inclusive span by surface position, not numeric seq\n * order; replacements can make visible seqs non-monotonic. Both edges must be\n * balanced so assistant tool calls remain paired with their results. A model-\n * backed implementation forwards cancellation and rejects active, missing,\n * reversed, or unbalanced ranges. The target session is `agent.session`.\n * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}.\n * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}\n * for the edge checks.\n *\n * @param start - first surface seq, inclusive.\n * @param end - last surface seq, inclusive.\n * @param agent - context whose session is mutated and whose routing options guide summarization.\n * @param signal - optional cancellation; model-backed implementations must forward it.\n * @throws when compaction is active or the range is missing, reversed, or unbalanced.\n * @returns the appended event seqs, summary, replaced range, and token accounting.\n */', + jsDoc: '/**\n * Forcibly compact a range of surface nodes into a single summary node.\n * `start` and `end` name an inclusive span by surface position, not numeric seq\n * order; replacements can make visible seqs non-monotonic. Both edges must be\n * balanced so assistant tool calls remain paired with their results. A model-\n * backed implementation forwards cancellation and rejects active, missing,\n * reversed, or unbalanced ranges. The target session is `agent.session`.\n * Its replacement user message must use {@link compactCheckpointSource} with\n * the transaction\'s `CompactionId`.\n * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}\n * for the edge checks.\n *\n * @param start - first surface seq, inclusive.\n * @param end - last surface seq, inclusive.\n * @param agent - context whose session is mutated and whose routing options guide summarization.\n * @param signal - optional cancellation; model-backed implementations must forward it.\n * @throws when compaction is active or the range is missing, reversed, or unbalanced.\n * @returns the appended event seqs, summary, replaced range, and token accounting.\n */', }, ], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fb20b9718..1c27b19685 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1507,6 +1507,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy From 1566a00eeba61e5f17f65a266eec4944e1ae335c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:42:39 +0800 Subject: [PATCH 22/32] test compact checkpoint snapshot identity --- .../acp-agent/tests/snapshots/workspace-context/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 179e61ade9..40592a9f99 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":14,"time":1785730689194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785730689194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9e9648c4-949e-4cf1-b9ef-0eb65897d36b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785730689195,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"user/message","seq":17,"time":1785982371865,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"162c764f-f01d-484d-ad81-1481dc29792a"},"sourceEventSeqs":[5],"surfaceOp":{"op":"replace","start":5,"end":5}} +{"type":"user/message","seq":17,"time":1785982371865,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact","compactionId":"workspace-context-fixture"},"role":"user","id":"162c764f-f01d-484d-ad81-1481dc29792a"},"sourceEventSeqs":[5],"surfaceOp":{"op":"replace","start":5,"end":5}} {"type":"tool/result","seq":18,"time":1785982371865,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"a46fded2-333a-4fb2-b01e-28520bffbc21"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","seq":19,"time":1785982371865,"data":{"turn":1,"step":1}} {"type":"agent/inbox/spliced","seq":20,"time":1785730689207,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"},{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"},{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"09640903-80ea-4eb6-8635-90ddfb4e24e4"}]}} From f4c817648d0685dd2bec9f3c1b474597e8473687 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:53:06 +0800 Subject: [PATCH 23/32] docs fix compact checkpoint type example --- ...2026-07-30-web-transcript-log-ordered-projection.i18n.yaml | 4 ++-- .../2026-07-30-web-transcript-log-ordered-projection.md | 4 ++-- .../2026-07-30-web-transcript-log-ordered-projection.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 1ea341085d..00fec6c8db 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.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/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: feefc951118c8511237c931a13070e1f7db1fd16 -2026-07-30-web-transcript-log-ordered-projection.zh.md: 711e57b29742171d1861f25e63019b64356b4cad +2026-07-30-web-transcript-log-ordered-projection.md: f2b7c3830b585db619f69046917e07a0b6ff6832 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 77787f9613b64a35e79a8667c6598de5a20ff400 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index feefc95111..f2b7c3830b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -33,8 +33,8 @@ What is unreachable from a `packages/client/*` program is `dsh-compact`'s **root The repo's answer to exactly this is a cordis-free leaf subpath, and this change adds one: `COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource` now live in `packages/compact/compact/src/checkpoint.ts`, which imports no cordis and augments no module (the `dsh-commands/brand` / `dsh-llm/message` shape), and the root re-exports both so every host-side consumer — the terminal's chat helpers, `dsh-session-reference`'s projection — is unchanged. The adapter pins its literal to that declaration with a type-only import: ```ts -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' ``` Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 711e57b297..77787f9613 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -33,8 +33,8 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 本仓库对这一情形的既有答案是不含 cordis 的叶子子路径,本次变更就新增了一个:`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource` 现在住在 `packages/compact/compact/src/checkpoint.ts`,它不导入 cordis、也不增强任何模块(即 `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根重新导出两者,因此每个宿主侧消费方——终端的 chat helper、`dsh-session-reference` 的投影——都不需改动。适配器用仅类型导入把它的字面量钉在该声明上: ```ts -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' ``` 重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 From 25dcd7293cca9cef7d2275b048462285d5090068 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:09:19 +0800 Subject: [PATCH 24/32] docs: purge chain-of-thought leakage from prose Delete design-session citations (decision/audit/plan ordinals, stack positions), change narration, review choreography, and reviewer-addressed justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and generator templates; restate every affected fact as current-state contract prose. Fix generated docs at their sources and regenerate the catalogs and cordis-surface regions; re-paste type-equiv blocks; update every bilingual counterpart and re-record the pairs. Record the citation rule in the committed-artifact-citations Agent Note. --- ...026-06-11-event-sourced-sessions.i18n.yaml | 4 +- .../2026-06-11-event-sourced-sessions.md | 2 +- .../2026-06-11-event-sourced-sessions.zh.md | 2 +- ...026-06-11-runtime-arg-validation.i18n.yaml | 4 +- .../2026-06-11-runtime-arg-validation.md | 2 +- .../2026-06-11-runtime-arg-validation.zh.md | 2 +- .../2026-06-14-session-persistence.i18n.yaml | 4 +- .../2026-06-14-session-persistence.md | 4 +- .../2026-06-14-session-persistence.zh.md | 4 +- ...06-17-filesystem-capability-seam.i18n.yaml | 4 +- .../2026-06-17-filesystem-capability-seam.md | 12 +- ...026-06-17-filesystem-capability-seam.zh.md | 12 +- .../2026-06-18-session-surface.i18n.yaml | 4 +- .../2026-06-18-session-surface.md | 2 +- .../2026-06-18-session-surface.zh.md | 2 +- ...ed-persistence-write-coordinator.i18n.yaml | 4 +- ...18-shared-persistence-write-coordinator.md | 2 +- ...shared-persistence-write-coordinator.zh.md | 2 +- .../2026-06-20-branded-ids.i18n.yaml | 4 +- .../architecture/2026-06-20-branded-ids.md | 14 +- .../architecture/2026-06-20-branded-ids.zh.md | 14 +- ...andatory-app-attribution-headers.i18n.yaml | 4 +- ...06-21-mandatory-app-attribution-headers.md | 10 +- ...21-mandatory-app-attribution-headers.zh.md | 10 +- .../2026-06-24-web-capability-seam.i18n.yaml | 4 +- .../2026-06-24-web-capability-seam.md | 4 +- .../2026-06-24-web-capability-seam.zh.md | 4 +- ...06-26-file-context-as-event-gate.i18n.yaml | 4 +- .../2026-06-26-file-context-as-event-gate.md | 2 +- ...026-06-26-file-context-as-event-gate.zh.md | 2 +- ...stdin-env-trusted-plugin-surface.i18n.yaml | 4 +- ...0-bash-stdin-env-trusted-plugin-surface.md | 2 +- ...ash-stdin-env-trusted-plugin-surface.zh.md | 2 +- ...026-06-30-event-domain-semantics.i18n.yaml | 4 +- .../2026-06-30-event-domain-semantics.md | 4 +- .../2026-06-30-event-domain-semantics.zh.md | 4 +- ...bles-and-tool-guidance-ownership.i18n.yaml | 4 +- ...t-variables-and-tool-guidance-ownership.md | 4 +- ...ariables-and-tool-guidance-ownership.zh.md | 4 +- ...6-07-05-reconstructable-requests.i18n.yaml | 4 +- .../2026-07-05-reconstructable-requests.md | 2 +- .../2026-07-05-reconstructable-requests.zh.md | 2 +- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +- .../2026-07-06-timeout-deadline-library.md | 4 +- .../2026-07-06-timeout-deadline-library.zh.md | 4 +- ...6-07-07-tool-call-timeout-policy.i18n.yaml | 4 +- .../2026-07-07-tool-call-timeout-policy.md | 3 +- .../2026-07-07-tool-call-timeout-policy.zh.md | 3 +- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 4 +- .../2026-07-15-lsp-capability-seam.zh.md | 4 +- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 12 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 12 +- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 12 +- ...26-07-19-gui-web-client-architecture.zh.md | 12 +- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 6 +- ...26-07-23-client-plugin-loading-model.zh.md | 6 +- ...-24-single-harness-home-resolver.i18n.yaml | 4 +- ...2026-07-24-single-harness-home-resolver.md | 2 +- ...6-07-24-single-harness-home-resolver.zh.md | 2 +- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 2 +- ...eb-command-surfaces-and-assembly.i18n.yaml | 4 +- ...07-25-web-command-surfaces-and-assembly.md | 6 +- ...25-web-command-surfaces-and-assembly.zh.md | 6 +- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 6 +- ...web-input-machine-and-slash-pipeline.zh.md | 6 +- .../2026-07-26-subprocess-seam.i18n.yaml | 4 +- .../2026-07-26-subprocess-seam.md | 2 +- .../2026-07-26-subprocess-seam.zh.md | 2 +- ...07-27-dispose-ladder-to-consumer.i18n.yaml | 4 +- .../2026-07-27-dispose-ladder-to-consumer.md | 2 +- ...026-07-27-dispose-ladder-to-consumer.zh.md | 2 +- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 +- .../2026-07-28-api-browser-trust-boundary.md | 4 +- ...026-07-28-api-browser-trust-boundary.zh.md | 4 +- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../2026-07-28-user-settings-seam.i18n.yaml | 4 +- .../2026-07-28-user-settings-seam.md | 4 +- .../2026-07-28-user-settings-seam.zh.md | 4 +- ...-07-29-dsh-source-launch-tsx-esm.i18n.yaml | 4 +- .../2026-07-29-dsh-source-launch-tsx-esm.md | 2 +- ...2026-07-29-dsh-source-launch-tsx-esm.zh.md | 2 +- .../2026-07-29-package-regrouping.i18n.yaml | 4 +- .../2026-07-29-package-regrouping.md | 24 +- .../2026-07-29-package-regrouping.zh.md | 24 +- ...-token-usage-and-request-context.i18n.yaml | 4 +- ...ojected-token-usage-and-request-context.md | 4 +- ...cted-token-usage-and-request-context.zh.md | 4 +- ...est-level-llm-config-credentials.i18n.yaml | 4 +- ...29-request-level-llm-config-credentials.md | 4 +- ...request-level-llm-config-credentials.zh.md | 4 +- ...26-07-30-config-plane-boundaries.i18n.yaml | 4 +- .../2026-07-30-config-plane-boundaries.md | 4 +- .../2026-07-30-config-plane-boundaries.zh.md | 4 +- ...undaries-and-atomic-registration.i18n.yaml | 4 +- ...tial-boundaries-and-atomic-registration.md | 8 +- ...l-boundaries-and-atomic-registration.zh.md | 10 +- ...30-session-end-seed-log-boundary.i18n.yaml | 4 +- ...026-07-30-session-end-seed-log-boundary.md | 2 +- ...-07-30-session-end-seed-log-boundary.zh.md | 2 +- ...30-settings-write-path-integrity.i18n.yaml | 4 +- ...026-07-30-settings-write-path-integrity.md | 6 +- ...-07-30-settings-write-path-integrity.zh.md | 6 +- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 10 +- .../2026-07-30-web-config-plane.zh.md | 10 +- ...runtime-portable-identifier-seam.i18n.yaml | 4 +- ...1-code-runtime-portable-identifier-seam.md | 8 +- ...ode-runtime-portable-identifier-seam.zh.md | 8 +- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- ...-pi-ai-declared-provider-catalog.i18n.yaml | 4 +- ...6-08-03-pi-ai-declared-provider-catalog.md | 2 +- ...8-03-pi-ai-declared-provider-catalog.zh.md | 2 +- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 2 +- ...08-04-configuration-source-ownership.zh.md | 2 +- ...-a-provider-from-the-models-page.i18n.yaml | 4 +- ...claring-a-provider-from-the-models-page.md | 2 +- ...ring-a-provider-from-the-models-page.zh.md | 2 +- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 14 +- ...06-subagent-list-identity-projection.zh.md | 14 +- ...d-scrollbars-and-reserved-gutter.i18n.yaml | 4 +- ...8-themed-scrollbars-and-reserved-gutter.md | 4 +- ...hemed-scrollbars-and-reserved-gutter.zh.md | 4 +- ...07-30-approval-panel-command-cap.i18n.yaml | 4 +- .../2026-07-30-approval-panel-command-cap.md | 2 +- ...026-07-30-approval-panel-command-cap.zh.md | 2 +- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 2 +- ...eb-transcript-log-ordered-projection.zh.md | 2 +- ...-fail-loud-releases-the-terminal.i18n.yaml | 4 +- ...6-07-31-fail-loud-releases-the-terminal.md | 2 +- ...7-31-fail-loud-releases-the-terminal.zh.md | 2 +- ...-08-06-api-key-format-validation.i18n.yaml | 4 +- .../2026-08-06-api-key-format-validation.md | 2 +- ...2026-08-06-api-key-format-validation.zh.md | 2 +- ...07-cancel-convergence-wake-latch.i18n.yaml | 4 +- ...026-08-07-cancel-convergence-wake-latch.md | 6 +- ...-08-07-cancel-convergence-wake-latch.zh.md | 6 +- .../feature/2026-06-15-code-mode.i18n.yaml | 4 +- .../feature/2026-06-15-code-mode.md | 4 +- .../feature/2026-06-15-code-mode.zh.md | 4 +- ...26-06-17-filesystem-tool-schemas.i18n.yaml | 4 +- .../2026-06-17-filesystem-tool-schemas.md | 12 +- .../2026-06-17-filesystem-tool-schemas.zh.md | 12 +- ...06-18-compaction-capability-seam.i18n.yaml | 4 +- .../2026-06-18-compaction-capability-seam.md | 2 +- ...026-06-18-compaction-capability-seam.zh.md | 2 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 4 +- .../2026-06-21-subagent-capability-seam.zh.md | 4 +- .../2026-06-29-todo-write-tool.i18n.yaml | 4 +- .../feature/2026-06-29-todo-write-tool.md | 2 +- .../feature/2026-06-29-todo-write-tool.zh.md | 2 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- ...026-06-30-session-store-fork-api.i18n.yaml | 4 +- .../2026-06-30-session-store-fork-api.md | 2 +- .../2026-06-30-session-store-fork-api.zh.md | 2 +- ...26-07-14-cross-family-fs-sandbox.i18n.yaml | 4 +- .../2026-07-14-cross-family-fs-sandbox.md | 2 +- .../2026-07-14-cross-family-fs-sandbox.zh.md | 2 +- .../2026-07-16-harness-level-loop.i18n.yaml | 4 +- .../feature/2026-07-16-harness-level-loop.md | 2 +- .../2026-07-16-harness-level-loop.zh.md | 2 +- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 1 - .../2026-07-16-persistent-pty-sessions.zh.md | 1 - ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 2 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 2 +- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 2 +- ...07-23-session-telemetry-otel-revival.zh.md | 2 +- ...n-list-browsing-and-manual-order.i18n.yaml | 4 +- ...-session-list-browsing-and-manual-order.md | 2 +- ...ssion-list-browsing-and-manual-order.zh.md | 2 +- ...26-07-26-code-dispatch-log-spill.i18n.yaml | 4 +- .../2026-07-26-code-dispatch-log-spill.md | 2 +- .../2026-07-26-code-dispatch-log-spill.zh.md | 4 +- ...7-26-code-dispatch-ui-foundation.i18n.yaml | 4 +- .../2026-07-26-code-dispatch-ui-foundation.md | 8 +- ...26-07-26-code-dispatch-ui-foundation.zh.md | 8 +- ...7-26-code-mode-chat-subcall-rows.i18n.yaml | 4 +- .../2026-07-26-code-mode-chat-subcall-rows.md | 2 +- ...26-07-26-code-mode-chat-subcall-rows.zh.md | 2 +- ...code-mode-live-parallel-dispatch.i18n.yaml | 4 +- ...-07-26-code-mode-live-parallel-dispatch.md | 6 +- ...-26-code-mode-live-parallel-dispatch.zh.md | 6 +- ...-07-26-todo-parallel-in-progress.i18n.yaml | 4 +- .../2026-07-26-todo-parallel-in-progress.md | 8 +- ...2026-07-26-todo-parallel-in-progress.zh.md | 8 +- ...ntinuable-subagent-conversations.i18n.yaml | 4 +- ...7-28-continuable-subagent-conversations.md | 4 +- ...8-continuable-subagent-conversations.zh.md | 4 +- ...28-todo-plan-clears-on-next-turn.i18n.yaml | 4 +- ...026-07-28-todo-plan-clears-on-next-turn.md | 2 +- ...-07-28-todo-plan-clears-on-next-turn.zh.md | 2 +- .../2026-07-28-web-terminal-card.i18n.yaml | 4 +- .../feature/2026-07-28-web-terminal-card.md | 6 +- .../2026-07-28-web-terminal-card.zh.md | 6 +- ...29-ask-question-web-presentation.i18n.yaml | 4 +- ...026-07-29-ask-question-web-presentation.md | 4 +- ...-07-29-ask-question-web-presentation.zh.md | 4 +- ...6-07-30-queued-manual-compaction.i18n.yaml | 4 +- .../2026-07-30-queued-manual-compaction.md | 2 +- .../2026-07-30-queued-manual-compaction.zh.md | 2 +- .../2026-07-30-search-render-card.i18n.yaml | 4 +- .../feature/2026-07-30-search-render-card.md | 6 +- .../2026-07-30-search-render-card.zh.md | 6 +- .../2026-07-30-web-diff-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-diff-card.md | 2 +- .../feature/2026-07-30-web-diff-card.zh.md | 2 +- ...026-07-30-web-read-card-frontend.i18n.yaml | 4 +- .../2026-07-30-web-read-card-frontend.md | 4 +- .../2026-07-30-web-read-card-frontend.zh.md | 4 +- .../2026-07-30-web-read-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-read-card.md | 4 +- .../feature/2026-07-30-web-read-card.zh.md | 4 +- ...6-07-30-web-result-card-frontend.i18n.yaml | 4 +- .../2026-07-30-web-result-card-frontend.md | 6 +- .../2026-07-30-web-result-card-frontend.zh.md | 6 +- .../2026-07-30-web-result-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-result-card.md | 4 +- .../feature/2026-07-30-web-result-card.zh.md | 4 +- .../2026-07-30-web-search-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-search-card.md | 4 +- .../feature/2026-07-30-web-search-card.zh.md | 4 +- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 6 +- ...26-07-31-code-mode-language-dispatch.zh.md | 8 +- ...7-31-telemetry-anonymous-user-id.i18n.yaml | 4 +- .../2026-07-31-telemetry-anonymous-user-id.md | 2 +- ...26-07-31-telemetry-anonymous-user-id.zh.md | 2 +- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 2 +- .../2026-07-31-web-workspace-file-links.zh.md | 2 +- ...2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 4 +- .../2026-08-02-pwsh-tool-bash-parity.md | 6 +- .../2026-08-02-pwsh-tool-bash-parity.zh.md | 6 +- ...2-win32-in-process-folder-dialog.i18n.yaml | 4 +- ...26-08-02-win32-in-process-folder-dialog.md | 2 +- ...08-02-win32-in-process-folder-dialog.zh.md | 2 +- ...6-08-03-web-search-source-scroll.i18n.yaml | 4 +- .../2026-08-03-web-search-source-scroll.md | 2 +- .../2026-08-03-web-search-source-scroll.zh.md | 2 +- ...4-web-composer-shared-width-axis.i18n.yaml | 4 +- ...26-08-04-web-composer-shared-width-axis.md | 2 +- ...08-04-web-composer-shared-width-axis.zh.md | 2 +- ...26-08-05-context-form-vocabulary.i18n.yaml | 4 +- .../2026-08-05-context-form-vocabulary.md | 2 +- .../2026-08-05-context-form-vocabulary.zh.md | 2 +- .../2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 +- .../feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- .../2026-06-17-ts-build-config.i18n.yaml | 4 +- .../process/2026-06-17-ts-build-config.md | 6 +- .../process/2026-06-17-ts-build-config.zh.md | 6 +- ...6-06-18-markdown-cross-link-lint.i18n.yaml | 4 +- .../2026-06-18-markdown-cross-link-lint.md | 2 +- .../2026-06-18-markdown-cross-link-lint.zh.md | 2 +- ...-20-core-data-structures-catalog.i18n.yaml | 4 +- ...2026-06-20-core-data-structures-catalog.md | 4 +- ...6-06-20-core-data-structures-catalog.zh.md | 4 +- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 4 +- ...6-07-02-bilingual-docs-and-pairing-gate.md | 2 +- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +- ...m-action-setup-for-symmetric-ci-caching.md | 2 +- ...ction-setup-for-symmetric-ci-caching.zh.md | 2 +- ...27-wine-windows-gates-experiment.i18n.yaml | 4 +- ...026-07-27-wine-windows-gates-experiment.md | 2 +- ...-07-27-wine-windows-gates-experiment.zh.md | 2 +- ...30-generated-third-party-notices.i18n.yaml | 4 +- ...026-07-30-generated-third-party-notices.md | 2 +- ...-07-30-generated-third-party-notices.zh.md | 2 +- ...staller-adopts-existing-checkout.i18n.yaml | 4 +- ...7-31-installer-adopts-existing-checkout.md | 4 +- ...1-installer-adopts-existing-checkout.zh.md | 4 +- ...-06-coverage-uncovered-locations.i18n.yaml | 4 +- ...2026-08-06-coverage-uncovered-locations.md | 2 +- ...6-08-06-coverage-uncovered-locations.zh.md | 2 +- ...-09-committed-artifact-citations.i18n.yaml | 6 + ...2026-08-09-committed-artifact-citations.md | 37 ++ ...6-08-09-committed-artifact-citations.zh.md | 37 ++ ...-19-drop-mutable-session-summary.i18n.yaml | 4 +- ...2026-06-19-drop-mutable-session-summary.md | 2 +- ...6-06-19-drop-mutable-session-summary.zh.md | 2 +- ...llapse-trace-only-session-events.i18n.yaml | 4 +- ...6-20-collapse-trace-only-session-events.md | 8 +- ...0-collapse-trace-only-session-events.zh.md | 8 +- ...-06-20-public-agent-stop-surface.i18n.yaml | 4 +- .../2026-06-20-public-agent-stop-surface.md | 6 +- ...2026-06-20-public-agent-stop-surface.zh.md | 4 +- ...ove-agent-boundary-mirror-events.i18n.yaml | 4 +- ...-20-remove-agent-boundary-mirror-events.md | 13 +- ...-remove-agent-boundary-mirror-events.zh.md | 13 +- ...06-20-unify-agent-and-session-id.i18n.yaml | 4 +- .../2026-06-20-unify-agent-and-session-id.md | 1 - ...026-06-20-unify-agent-and-session-id.zh.md | 1 - .../2026-06-26-fsspec-style-fs-seam.i18n.yaml | 4 +- .../2026-06-26-fsspec-style-fs-seam.md | 6 +- .../2026-06-26-fsspec-style-fs-seam.zh.md | 6 +- ...6-07-04-drop-image-content-block.i18n.yaml | 4 +- .../2026-07-04-drop-image-content-block.md | 2 +- .../2026-07-04-drop-image-content-block.zh.md | 2 +- ...4-tighten-hook-protocol-contract.i18n.yaml | 4 +- ...26-07-04-tighten-hook-protocol-contract.md | 2 +- ...07-04-tighten-hook-protocol-contract.zh.md | 2 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 2 +- .../2026-07-17-one-send-one-turn.zh.md | 2 +- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 8 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 8 +- ...026-06-11-property-based-testing.i18n.yaml | 4 +- .../2026-06-11-property-based-testing.md | 4 +- .../2026-06-11-property-based-testing.zh.md | 4 +- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 4 +- .../testing/2026-06-19-acp-snapshot-tests.md | 4 +- .../2026-06-19-acp-snapshot-tests.zh.md | 4 +- .../2026-06-19-real-api-e2e-ci.i18n.yaml | 4 +- .../testing/2026-06-19-real-api-e2e-ci.md | 4 +- .../testing/2026-06-19-real-api-e2e-ci.zh.md | 4 +- ...6-06-22-subagent-snapshot-replay.i18n.yaml | 4 +- .../2026-06-22-subagent-snapshot-replay.md | 4 +- .../2026-06-22-subagent-snapshot-replay.zh.md | 4 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- .../2026-06-16-typed-event-schemas.i18n.yaml | 4 +- .../2026-06-16-typed-event-schemas.md | 2 - .../2026-06-16-typed-event-schemas.zh.md | 2 - ...-domain-kv-storage-and-workspace.i18n.yaml | 4 +- ...6-07-24-domain-kv-storage-and-workspace.md | 4 +- ...7-24-domain-kv-storage-and-workspace.zh.md | 4 +- ...ssion-projection-and-command-log.i18n.yaml | 4 +- ...7-27-session-projection-and-command-log.md | 6 +- ...7-session-projection-and-command-log.zh.md | 6 +- ...root-and-derived-medium-recovery.i18n.yaml | 4 +- ...torage-root-and-derived-medium-recovery.md | 6 +- ...age-root-and-derived-medium-recovery.zh.md | 6 +- ...2026-07-06-recallable-compaction.i18n.yaml | 4 +- .../2026-07-06-recallable-compaction.md | 2 +- .../2026-07-06-recallable-compaction.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 +- .../2026-08-01-windows-pwsh-default.md | 2 +- .../2026-08-01-windows-pwsh-default.zh.md | 2 +- ...2026-06-11-api-extractor-reports.i18n.yaml | 4 +- .../2026-06-11-api-extractor-reports.md | 2 +- .../2026-06-11-api-extractor-reports.zh.md | 2 +- .agents/skills/dsh-doc-standards/SKILL.md | 2 +- .../skills/dsh-merging-stacked-prs/SKILL.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 2 +- .agents/skills/record-browser-gif/SKILL.md | 2 +- .github/issue-management/policy.mjs | 2 +- .github/workflows/ci.yml | 7 +- apps/cli/tests/args.spec.ts | 6 +- apps/cli/tests/built-bin.e2e.ts | 12 +- apps/web/tests/approval-composer.e2e.ts | 13 +- apps/web/tests/chat-long-interactions.e2e.ts | 7 +- apps/web/tests/code-mode-round.e2e.ts | 4 +- apps/web/tests/composer-draft-scroll.e2e.ts | 27 +- apps/web/tests/composer-tab-geometry.e2e.ts | 54 +- .../tests/conversation-column-overflow.e2e.ts | 53 +- apps/web/tests/default-model.overlay.yml | 2 +- apps/web/tests/lifecycle-chrome.e2e.ts | 11 +- apps/web/tests/models-settings.e2e.ts | 5 +- apps/web/tests/navigation-panes.e2e.ts | 2 +- apps/web/tests/pin-browse-picker.overlay.yml | 2 +- apps/web/tests/pwsh-terminal.e2e.ts | 4 +- apps/web/tests/scaffold.ts | 2 +- apps/web/tests/seeded-history.e2e.ts | 4 +- apps/web/tests/settings-chrome.e2e.ts | 2 +- apps/web/tests/sidebar-scrollbar.e2e.ts | 38 +- apps/web/tests/smoke-real.e2e.ts | 30 +- apps/web/tests/startup-auto-selection.e2e.ts | 13 +- apps/web/tests/subagent-interrupt-ui.e2e.ts | 3 +- apps/web/tests/subagent-interrupt.e2e.ts | 2 +- apps/web/tests/turn-tail-actions.e2e.ts | 2 +- apps/web/tests/workspace-management.e2e.ts | 16 +- apps/web/vite.config.ts | 12 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 10 +- docs/api-gateway.zh.md | 10 +- docs/capability-seams.md | 2 +- docs/config-catalog.md | 16 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 4 +- docs/cookbook/extension-cookbook.zh.md | 4 +- .../maintaining-dsh-code-review.i18n.yaml | 4 +- docs/cookbook/maintaining-dsh-code-review.md | 2 +- .../maintaining-dsh-code-review.zh.md | 2 +- docs/cordis-api/inherited.md | 2 +- docs/cordis-tutorial/04-events.i18n.yaml | 4 +- docs/cordis-tutorial/04-events.md | 2 +- docs/cordis-tutorial/04-events.zh.md | 2 +- .../06-composition-and-hmr.i18n.yaml | 4 +- .../cordis-tutorial/06-composition-and-hmr.md | 2 +- .../06-composition-and-hmr.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/i18n/translation-prompt.md | 2 +- docs/persistence-catalog.md | 5 +- docs/subsystems/approval.i18n.yaml | 4 +- docs/subsystems/approval.md | 5 +- docs/subsystems/approval.zh.md | 5 +- docs/subsystems/bash.i18n.yaml | 4 +- docs/subsystems/bash.md | 4 +- docs/subsystems/bash.zh.md | 4 +- docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 2 +- docs/subsystems/client-modules.zh.md | 2 +- docs/subsystems/code-runtime.i18n.yaml | 4 +- docs/subsystems/code-runtime.md | 2 +- docs/subsystems/code-runtime.zh.md | 2 +- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 2 +- docs/subsystems/compaction.zh.md | 2 +- docs/subsystems/filesystem.i18n.yaml | 4 +- docs/subsystems/filesystem.md | 2 +- docs/subsystems/filesystem.zh.md | 2 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 8 +- docs/subsystems/llm-streaming.zh.md | 8 +- docs/subsystems/lsp.i18n.yaml | 4 +- docs/subsystems/lsp.md | 2 +- docs/subsystems/lsp.zh.md | 2 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 4 +- docs/subsystems/persistence.zh.md | 4 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 2 +- docs/subsystems/session-projection.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 5 +- docs/subsystems/session.zh.md | 5 +- docs/subsystems/skills.i18n.yaml | 4 +- docs/subsystems/skills.md | 2 +- docs/subsystems/skills.zh.md | 2 +- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 4 +- docs/subsystems/storage.zh.md | 4 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 4 +- docs/subsystems/subagent.zh.md | 4 +- docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 4 +- docs/subsystems/subprocess.zh.md | 4 +- docs/subsystems/telemetry.i18n.yaml | 4 +- docs/subsystems/telemetry.md | 2 +- docs/subsystems/telemetry.zh.md | 2 +- docs/subsystems/web.i18n.yaml | 4 +- docs/subsystems/web.md | 13 +- docs/subsystems/web.zh.md | 13 +- docs/testing.i18n.yaml | 4 +- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- docs/tool-catalog.md | 4 +- docs/user/guide/config.i18n.yaml | 4 +- docs/user/guide/config.md | 2 +- docs/user/guide/config.zh.md | 2 +- docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- examples/acp-agent/tests/acp.e2e.ts | 2 +- examples/acp-agent/tests/escalation.e2e.ts | 2 +- .../headless-agent/tests/headless.snapshot.ts | 10 +- native/landlock-run/README.i18n.yaml | 4 +- native/landlock-run/README.md | 2 +- native/landlock-run/README.zh.md | 2 +- native/landlock-run/packages/entry/src/main.c | 9 +- packages/bash/bash-sandbox/src/index.ts | 2 +- packages/bash/bash/README.i18n.yaml | 4 +- packages/bash/bash/README.md | 2 +- packages/bash/bash/README.zh.md | 2 +- packages/bash/pwsh-local/README.i18n.yaml | 4 +- packages/bash/pwsh-local/README.md | 2 +- packages/bash/pwsh-local/README.zh.md | 2 +- packages/bash/tool-bash/src/index.ts | 6 +- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/bundle/web-app/src/index.ts | 2 +- packages/client/AGENTS.md | 4 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/client/api.ts | 8 +- .../connection/src/client/connection.ts | 12 +- .../client/connection/src/client/fixture.ts | 12 +- packages/client/hmr/src/client/index.ts | 7 +- packages/client/modules/src/client/index.ts | 4 +- .../client/modules/src/client/manifest.ts | 12 +- packages/client/modules/src/client/system.ts | 2 +- packages/client/modules/src/index.ts | 4 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 4 +- packages/client/runtime/README.zh.md | 4 +- .../runtime/src/client/contract/store.ts | 6 +- packages/client/runtime/src/client/index.ts | 3 +- .../runtime/src/client/sessions/manager.ts | 14 +- .../src/client/sessions/projection-store.ts | 11 +- .../runtime/src/client/sessions/service.ts | 7 +- .../runtime/src/client/sessions/session.ts | 25 +- .../src/client/sessions/transcript-adapter.ts | 408 +++++++++++++ .../client/runtime/tests/client-apply.spec.ts | 2 +- packages/client/runtime/tests/event-script.ts | 2 +- .../runtime/tests/projection-store.spec.ts | 3 +- packages/client/runtime/tests/session.spec.ts | 2 +- .../runtime/tests/transcript-adapter.spec.ts | 567 ++++++++++++++++++ .../ui-command/src/client/PopupSelectView.tsx | 4 +- .../client/ui-command/src/client/popup.ts | 2 +- .../ui-command/tests/popup-view.spec.tsx | 2 +- .../client/ui-command/tests/popup.spec.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 8 +- .../src/client/chat/ChatView.tsx | 2 +- .../src/client/chat/MessageItem.tsx | 4 +- .../src/client/contract/slots.ts | 19 +- .../src/client/input/contract.ts | 10 +- .../src/client/input/decorations.ts | 12 +- .../src/client/input/facade.ts | 11 +- .../ui-conversation/src/client/input/hub.ts | 6 +- .../src/client/input/machine.ts | 14 +- .../ui-conversation/src/client/queue/store.ts | 2 +- .../ui-conversation/src/client/service.ts | 2 +- .../src/client/skeleton/ApprovalPanel.tsx | 3 +- .../src/client/skeleton/ContextMeter.tsx | 4 +- .../src/client/skeleton/ConversationRoot.tsx | 1 - .../src/client/skeleton/DetailsPanel.tsx | 5 +- .../src/client/skeleton/InputBar.module.css | 2 +- .../src/client/skeleton/InputBar.tsx | 9 +- .../ui-conversation/tests/input-bar.spec.tsx | 14 +- .../tests/input-machine.spec.ts | 6 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/todo-panel.spec.tsx | 4 +- .../client/ui-layout/src/client/AppFrame.tsx | 4 +- .../client/ui-layout/tests/app-frame.spec.tsx | 2 +- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 4 +- .../src/client/CustomProviderCard.tsx | 2 +- .../src/client/ModelsSection.module.css | 8 +- .../ui-models/tests/components.spec.tsx | 4 +- .../ui-models/tests/provider-form.spec.tsx | 12 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../ui-primitives/src/TerminalBlock.tsx | 4 +- .../ui-primitives/src/Tooltip.module.css | 4 +- .../ui-primitives/src/markdown/highlight.ts | 3 +- .../client/ui-primitives/tests/ansi.spec.ts | 8 +- .../ui-primitives/tests/hover-card.spec.tsx | 4 +- .../client/ui-primitives/tests/icons.spec.tsx | 2 +- .../client/ui-primitives/tsdown.config.ts | 2 +- .../ui-settings/src/client/contract/slots.ts | 4 +- .../src/client/SidebarRoot.module.css | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 14 +- .../ui-skill/tests/browser-plugin.spec.ts | 6 +- packages/client/ui-slash/README.i18n.yaml | 4 +- packages/client/ui-slash/README.md | 2 +- packages/client/ui-slash/README.zh.md | 2 +- .../client/ui-slash/src/client/controller.ts | 5 +- packages/client/ui-slash/src/client/slots.ts | 2 +- packages/client/ui-slash/src/core/contract.ts | 8 +- packages/client/ui-slash/src/core/detect.ts | 2 +- packages/client/ui-slash/src/core/menu.ts | 2 +- packages/client/ui-slash/src/types.ts | 12 +- .../client/ui-slash/tests/core-detect.spec.ts | 4 +- .../client/ui-slash/tests/core-menu.spec.ts | 3 +- .../client/ui-slash/tests/menu-view.spec.tsx | 2 +- .../client/ui-slash/tests/service.spec.ts | 2 +- packages/client/ui-slots/src/index.ts | 2 +- packages/client/ui-slots/src/renderer.ts | 5 +- .../client/ui-subagent/src/client/index.ts | 12 +- .../ui-subagent/tests/browser-plugin.spec.ts | 6 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 4 +- packages/client/ui-theme/README.zh.md | 4 +- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 6 +- packages/client/ui-tool/README.zh.md | 6 +- .../src/client/tool/toolviews/search-row.tsx | 4 +- .../client/ui-tool/tests/tool-row.spec.tsx | 4 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../client/ui-trajectory/src/client/layout.ts | 4 +- .../src/client/WorkspaceBrowser.tsx | 4 +- .../ui-workspace/src/client/rows/Rows.tsx | 6 +- .../tests/rename-assembly.spec.tsx | 5 +- packages/client/web-react/README.i18n.yaml | 4 +- packages/client/web-react/README.md | 2 +- packages/client/web-react/README.zh.md | 2 +- .../client/web-react/src/scoped-slots.tsx | 4 +- .../client/web-react/src/session-provider.tsx | 2 +- .../web-react/tests/scoped-slots.spec.tsx | 4 +- .../web-react/tests/session-provider.spec.tsx | 4 +- .../web-react/tests/use-projection.spec.tsx | 3 +- packages/client/web/src/AppRoot.tsx | 2 +- packages/client/web/src/boot.tsx | 10 +- packages/client/web/src/index.ts | 2 +- packages/client/web/src/loader-status.ts | 4 +- packages/client/web/src/seed.ts | 2 +- .../code-runtime/code-runtime/src/index.ts | 14 +- packages/context/README.i18n.yaml | 4 +- packages/context/README.md | 2 - packages/context/README.zh.md | 4 +- .../tests/workspace-context.spec.ts | 2 +- packages/core/session/src/chunk-rows.ts | 3 +- packages/core/session/src/types.ts | 3 +- packages/core/tools/src/code-mode.ts | 8 +- packages/core/tools/src/index.ts | 4 +- packages/core/tools/src/py-types.ts | 16 +- .../credentials-local/src/index.ts | 5 +- .../credentials-local/tests/local.spec.ts | 4 +- packages/fs/README.i18n.yaml | 4 +- packages/fs/README.md | 8 +- packages/fs/README.zh.md | 8 +- packages/fs/fs/README.i18n.yaml | 4 +- packages/fs/fs/README.md | 2 +- packages/fs/fs/README.zh.md | 2 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 2 +- packages/fs/tool-fs-search/README.zh.md | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/goal/goal/src/domain.ts | 6 +- .../guard/timeout-policy/README.i18n.yaml | 4 +- packages/guard/timeout-policy/README.md | 2 +- packages/guard/timeout-policy/README.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 2 +- packages/hooks/hook-protocol/README.zh.md | 2 +- packages/hooks/hooks-claude/src/index.ts | 2 +- packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- packages/hooks/hooks-codex/src/index.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 6 +- packages/host/apiproxy/src/index.ts | 4 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 8 +- .../apiproxy/tests/api-proxy-config.spec.ts | 7 +- .../src/client/DirectoryBrowser.tsx | 3 +- .../src/native-picker.ts | 4 +- .../interaction/user-approval/src/index.ts | 3 +- .../tests/loader-composition.spec.ts | 2 +- packages/llm/llm-pi-ai/src/catalog.ts | 9 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/message.ts | 4 +- packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 4 +- packages/llm/token-meter/README.zh.md | 4 +- packages/lsp/lsp/src/types.ts | 2 +- packages/mcp/mcp-client/src/tools.ts | 5 +- packages/mcp/mcp-client/src/transport.ts | 5 +- packages/mcp/mcp-client/tests/apply.spec.ts | 1 - .../mcp/mcp-client/tests/mcp-client.spec.ts | 7 +- packages/scaffold/README.i18n.yaml | 4 +- packages/scaffold/README.md | 2 +- packages/scaffold/README.zh.md | 2 +- packages/scaffold/telemetry/README.i18n.yaml | 4 +- packages/scaffold/telemetry/README.md | 2 +- packages/scaffold/telemetry/README.zh.md | 2 +- packages/self-modification/README.i18n.yaml | 4 +- packages/self-modification/README.md | 2 +- packages/self-modification/README.zh.md | 2 +- .../tool-cordis/src/api-catalog.ts | 2 +- .../session-query-sqlite/README.i18n.yaml | 4 +- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/README.zh.md | 2 +- .../tests/jsonl.spec.ts | 1 - .../README.i18n.yaml | 4 +- .../session-persistence-sqlite/README.md | 5 +- .../session-persistence-sqlite/README.zh.md | 5 +- .../session-projection-cache/src/spec.ts | 3 +- .../session-telemetry-otel/src/index.ts | 6 +- .../session/session-telemetry/src/index.ts | 2 +- packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 10 +- .../storage/storage-sqlite/README.i18n.yaml | 4 +- packages/storage/storage-sqlite/README.md | 4 +- packages/storage/storage-sqlite/README.zh.md | 4 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/index.ts | 2 +- packages/subagent/subagent-acp/src/run.ts | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 4 +- packages/subagent/subagent-codex/README.zh.md | 4 +- .../subagent-fork/tests/subagent-fork.spec.ts | 2 - .../subagent/subagent/src/list-children.ts | 9 +- packages/subagent/subagent/src/types.ts | 2 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 4 +- .../subprocess/subprocess-local/README.zh.md | 4 +- packages/subprocess/subprocess/src/index.ts | 4 +- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 4 +- packages/support/llm-replay/README.i18n.yaml | 4 +- packages/support/llm-replay/README.md | 2 +- packages/support/llm-replay/README.zh.md | 2 +- packages/tasks/tasks/src/index.ts | 7 +- packages/todo/tool-todo/src/index.ts | 1 - .../typert/generator/src/cordis-catalog.ts | 2 +- packages/util/retention/README.i18n.yaml | 4 +- packages/util/retention/README.md | 2 +- packages/util/retention/README.zh.md | 2 +- packages/util/retention/src/index.ts | 5 +- packages/util/timeout/README.i18n.yaml | 4 +- packages/util/timeout/README.md | 2 +- packages/util/timeout/README.zh.md | 2 +- .../web/web-search-deepseek/README.i18n.yaml | 4 +- packages/web/web-search-deepseek/README.md | 2 +- packages/web/web-search-deepseek/README.zh.md | 2 +- packages/web/web/README.i18n.yaml | 4 +- packages/web/web/README.md | 2 +- packages/web/web/README.zh.md | 2 +- packages/web/web/src/types.ts | 7 +- .../workflow/tool-workflow/README.i18n.yaml | 4 +- packages/workflow/tool-workflow/README.md | 2 +- packages/workflow/tool-workflow/README.zh.md | 2 +- scripts/gen-doc-graphs.ts | 4 +- scripts/gen-tool-catalog.ts | 2 +- scripts/hero-composer-dom-continuity.mjs | 2 +- scripts/translation-brief.ts | 7 +- scripts/verify-client-domain-graph.ts | 8 +- vitest.config.ts | 10 +- 763 files changed, 2705 insertions(+), 1710 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md create mode 100644 .agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md create mode 100644 packages/client/runtime/src/client/sessions/transcript-adapter.ts create mode 100644 packages/client/runtime/tests/transcript-adapter.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml index 0eb64b3107..a061e142cb 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.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-06-11-event-sourced-sessions.md -2026-06-11-event-sourced-sessions.md: 01f9628c1cfc000aca8654caf5edeff09411fdcc -2026-06-11-event-sourced-sessions.zh.md: 5de230de9e9f1303943b43f6fdeefaf8264aa3bf +2026-06-11-event-sourced-sessions.md: 1303ee843cd5f9e02eae7a1f9bc51594be4bb407 +2026-06-11-event-sourced-sessions.zh.md: d1800ab3820ba5fc60723d6851fe71eded38134f diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md index 01f9628c1c..1303ee843c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md @@ -25,4 +25,4 @@ Ordering contract: the loop claims inbox messages before `agent/pre-step`, opens - Replay, trace, and telemetry are structurally guaranteed, not bolted on. - Persistence stays a plugin concern; the in-memory store ships in dsh-session. - The event vocabulary is merge-extensible (plugins add e.g. compaction events); [session persistence](2026-06-14-session-persistence.md) froze its shape once the log became durable. -- Derivation cost grows with log length — compaction (future plugin) is the intended mitigation, not log mutation. +- Derivation cost grows with log length — compaction (dsh-compact) is the intended mitigation, not log mutation. diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md index 5de230de9e..d1800ab382 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -25,4 +25,4 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严 - 回放、追踪与遥测在结构上得到保证,而非事后附加。 - 持久化仍是插件关注点;内存存储随 dsh-session 一起提供。 - 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志具备持久性后固定了其结构。 -- 派生成本随日志长度增长,压缩(未来插件)是预期的缓解手段,而非日志变更。 +- 派生成本随日志长度增长,压缩(dsh-compact)是预期的缓解手段,而非日志变更。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml index b18ca244be..260604acc4 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.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-06-11-runtime-arg-validation.md -2026-06-11-runtime-arg-validation.md: e0bca0ff24c5adc7ca58007932dff6580694b01d -2026-06-11-runtime-arg-validation.zh.md: c98bde0ed2dd568a394aae74fb9f5549c3f61105 +2026-06-11-runtime-arg-validation.md: 520823c76f147205805d306e97b5461f38b84c35 +2026-06-11-runtime-arg-validation.zh.md: ccb9b50d8b8a932d17516e579a5364321b64577b diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md index e0bca0ff24..520823c76f 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md @@ -18,7 +18,7 @@ The validator and compiler therefore share exact semantics: the implicit paramet - The model gets actionable feedback on its own malformed calls instead of an opaque crash, closing the gap between `InferArgs`'s promise and runtime reality. - The validator and `InferArgs` must stay in agreement; [a property test](../testing/2026-06-11-property-based-testing.md) generates args satisfying a spec and asserts they pass `validateArgs` (with targeted corruptions rejected), closing that drift risk mechanically. -- `ToolArgsError` is a plain `Error` with a `code` field for now; if a harness-wide error taxonomy lands it becomes a subclass without changing callers that read `.message`. +- `ToolArgsError` subclasses `HarnessError` from the [structured error taxonomy](2026-06-11-structured-error-taxonomy.md), keeping its `code` field; callers that read `.message` are unaffected by the hierarchy. - Validation cost is negligible next to a model call. diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md index c98bde0ed2..ccb9b50d8b 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md @@ -18,7 +18,7 @@ Status: implemented - 模型在自身畸形调用上获得可操作的反馈,而非不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。 - 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时断言定向破坏的参数被拒绝),以机械方式封堵漂移风险。 -- `ToolArgsError` 目前是带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,但不影响读取 `.message` 的调用方。 +- `ToolArgsError` 是[结构化错误分类体系](2026-06-11-structured-error-taxonomy.md)中 `HarnessError` 的子类,保留其 `code` 字段;读取 `.message` 的调用方不受该层级结构影响。 - 校验开销相对于一次模型调用可忽略不计。 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index f2b2982bc1..a28df36350 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-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 .agents/notes/implemented/architecture/2026-06-14-session-persistence.md -2026-06-14-session-persistence.md: 46fd3755a3c35bd342119fb59b560775a679f740 -2026-06-14-session-persistence.zh.md: f29b192ec9814de2a183599bcfc0a4a0c301b31e +2026-06-14-session-persistence.md: 6961b8f3f4274587c2920fe2b2602aff5efe4afc +2026-06-14-session-persistence.zh.md: 58c8bb80a1f886c986fc5aa1a0ba6b7f263ad829 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 46fd3755a3..6961b8f3f4 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -17,7 +17,7 @@ Persistence is a **capability seam** with an abstract Service Definition ([capab 1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type. 2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Eligible `assistant/chunk` delta runs use packed rows by default; [checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable. -Key choices recorded here because they are durable, contested, and surprising: +Key durable, contested choices: - **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and validation of `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. - **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. @@ -29,7 +29,7 @@ Key choices recorded here because they are durable, contested, and surprising: Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. +Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index f29b192ec9..58c8bb80a1 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -17,7 +17,7 @@ Status: implemented 1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md)是默认物理编码,也可通过配置使用原始行。 -以下关键选择记录于此,因为它们长期有效、存在争议且出人意料: +长期有效、存在争议的关键选择: - **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 - **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些 closer;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 @@ -29,7 +29,7 @@ Status: implemented 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(冷准备时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 +格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。仅追加 + 刷写对部分尾部写入是健壮的(冷准备时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是该场景下更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 159483fb73..68bcd74197 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md -2026-06-17-filesystem-capability-seam.md: 35fe5148ca44f733aa0a354e215cd1d77eb8180e -2026-06-17-filesystem-capability-seam.zh.md: 998b5ead48dcaf168ad48c8ad7a403a7e3b3e955 +2026-06-17-filesystem-capability-seam.md: 928ad4926e46ed5b6b35552b75882d565d8ad6db +2026-06-17-filesystem-capability-seam.zh.md: 02af77a3cc291eeba2be966a4bc08aa006675e7d diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 35fe5148ca..928ad4926e 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -6,7 +6,7 @@ English | [中文](2026-06-17-filesystem-capability-seam.zh.md) ## Problem -The harness has a concrete `bash` capability seam (`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`), but filesystem operations are about to be added as model-facing tools without an equivalent seam. If `read`, `write`, and `edit` directly use `node:fs`, the model-facing tool package will own filesystem execution policy, local path resolution, atomic write behavior, text decoding, symlink behavior, and edit semantics all at once. +The harness has a concrete `bash` capability seam (`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`), but filesystem operations were about to land as model-facing tools without an equivalent seam. If `read`, `write`, and `edit` directly used `node:fs`, the model-facing tool package would own filesystem execution policy, local path resolution, atomic write behavior, text decoding, symlink behavior, and edit semantics all at once. That couples three concerns that change independently: @@ -16,7 +16,7 @@ That couples three concerns that change independently: Without a `ctx.fs` interface, swapping local filesystem access for a sandboxed or remote backend would churn the tool schemas, demos, and prompt guidance even when the model-facing contract should stay stable. It also makes permission/sandbox boundaries harder to reason about: a `cwd` option can look like a sandbox even though it is only a base path unless an explicit backend or `tools/execute` policy enforces containment. -We need the filesystem tools to land in the same capability-seam shape as bash before they become a public package surface. +The filesystem tools must land in the same capability-seam shape as bash before they become a public package surface. ## Decision @@ -28,7 +28,7 @@ Filesystem access is a first-class capability seam following [the capability-sea The Consumer package depends only on the Service Definition package, never on `dsh-fs-local`. A deployment that wants a different backend loads a different provider for `ctx.fs` without changing the tool schemas or model-facing prompt guidance. -The read-before-write/edit and observed-state policy is a fourth package, `@deepseek-ai/dsh-fs-policy` (`packages/fs/fs-policy`), contributed through the `fs/*` event gate rather than living on `ctx.fs`; a deployment loading `dsh-tool-fs` also loads `dsh-fs-policy` to get read-before-write/edit. This Agent Note established the three-package boundary; the split of policy off the provider base class is decided by [the split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md), and its realization as an event-gate plugin (not a method service) by [the event-gate Agent Note](2026-06-26-file-context-as-event-gate.md). This document is updated to describe that landed four-package shape. +The read-before-write/edit and observed-state policy is a fourth package, `@deepseek-ai/dsh-fs-policy` (`packages/fs/fs-policy`), contributed through the `fs/*` event gate rather than living on `ctx.fs`; a deployment loading `dsh-tool-fs` also loads `dsh-fs-policy` to get read-before-write/edit. This decision established the three-package boundary; the split of policy off the provider base class is decided by [the split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md), and its realization as an event-gate plugin (not a method service) by [the event-gate Agent Note](2026-06-26-file-context-as-event-gate.md). The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface. @@ -74,7 +74,7 @@ The provider contract also carries the freshness hooks that policy builds on — - `writeText`/`editText` take an OPTIONAL version expectation: omit it for an unconditional bare-provider mutation, or supply it to guard the mutation inside the backend's atomic critical section. - The `dsh-fs-policy` plugin decides that expectation on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`, keyed by an owner it derives from the opaque event actor (normally `exec.agent.session`). -Authorization is version freshness, not a full/partial view distinction: any read records the target's version, and a later write/edit is authorized as long as the file is still at that version — so a windowed read of lines 100-150 authorizes an edit of line 120. The observed-state store is a `WeakMap>` inside `dsh-fs-policy`; `dsh-fs` holds none of it and treats the actor as opaque. (This Agent Note first modeled a `FileState` cache with `full`/`partial` views on `ctx.fs`; the split-fs-seam and event-gate Agent Notes replaced that with the freshness-based policy plugin described here.) +Authorization is version freshness, not a full/partial view distinction: any read records the target's version, and a later write/edit is authorized as long as the file is still at that version — so a windowed read of lines 100-150 authorizes an edit of line 120. The observed-state store is a `WeakMap>` inside `dsh-fs-policy`; `dsh-fs` holds none of it and treats the actor as opaque. (This decision first modeled a `FileState` cache with `full`/`partial` views on `ctx.fs`; the split-fs-seam and event-gate notes replaced that with the freshness-based policy plugin described here.) Path resolution is explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity. @@ -148,11 +148,11 @@ The defensive-pattern classes this repo has been bitten by are pinned directly: **The interface can become too local.** Returning fields such as `absolutePath` from `ctx.fs` would make remote, sandboxed, or virtual backends awkward. The contract should expose display metadata without requiring consumers to understand host paths. -**The interface can become too thin.** If `ctx.fs` only mirrors `node:fs` primitives, `tool-fs` will reimplement binary detection, pagination, atomic writes, and edit semantics. That recreates the coupling this Agent Note is trying to avoid. +**The interface can become too thin.** If `ctx.fs` only mirrors `node:fs` primitives, `tool-fs` will reimplement binary detection, pagination, atomic writes, and edit semantics. That recreates the coupling this decision avoids. **Edit semantics are race-prone by nature.** Literal edit is a read-modify-write operation; the guard is the backend's atomic mutation critical section plus the optional version expectation, so concurrent edits settle deterministically — one wins, the other gets `FS_STALE_VERSION`. -**Observed state does not belong on `ctx.fs`.** Recording what an execution context has seen is workflow policy, not raw filesystem I/O. This Agent Note first placed it inside the filesystem seam; the split-fs-seam Agent Note then established that a sandboxed/remote backend should not inherit model-facing observation policy, and moved it into the `dsh-fs-policy` plugin. The provider contract keeps only what write/edit safety genuinely needs at the storage layer — a backend-minted version token and an optional version-guarded mutation — while the policy plugin owns owner derivation, observed-state, and read-before-edit gating over the `fs/*` events. +**Observed state does not belong on `ctx.fs`.** Recording what an execution context has seen is workflow policy, not raw filesystem I/O. This decision first placed it inside the filesystem seam; the split-fs-seam note then established that a sandboxed/remote backend should not inherit model-facing observation policy, and moved it into the `dsh-fs-policy` plugin. The provider contract keeps only what write/edit safety genuinely needs at the storage layer — a backend-minted version token and an optional version-guarded mutation — while the policy plugin owns owner derivation, observed-state, and read-before-edit gating over the `fs/*` events. **The `resolve`-then-operate shape costs an extra round-trip per call.** Each tool may resolve a path to an `FsTarget` and then issue the read/write/edit as a separate `ctx.fs` call. For the local backend this is negligible (resolution is in-memory path normalization), but a remote/sandboxed backend may turn each step into its own request, so a single `read` can become two network round-trips. Backends where the round-trip matters can cache or fold resolution internally while preserving the observable contract. diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index 998b5ead48..02af77a3cc 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作即将作为面向模型的工具加入,却没有等价的 seam。如果 `read`、`write` 和 `edit` 直接使用 `node:fs`,面向模型的工具包将同时承担文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。 +harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作当时即将作为面向模型的工具落地,却没有等价的 seam。如果 `read`、`write` 和 `edit` 直接使用 `node:fs`,面向模型的工具包就会同时承担文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。 这把三个独立变化的关注点耦合在了一起: @@ -16,7 +16,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` 如果没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的约定应当保持稳定,工具 schema、演示和提示词引导也会被迫变动。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式的后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。 -我们需要文件系统工具在成为公开包(package)接口之前,以与 bash 相同的能力 seam 形态落地。 +文件系统工具必须在成为公开包(package)接口之前,以与 bash 相同的能力 seam 形态落地。 ## 决策 @@ -28,7 +28,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-bash` / `dsh-bash-local` Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。 -读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 Agent Note 确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。 +读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy`(`packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本决策确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。 第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。 @@ -74,7 +74,7 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 - `writeText`/`editText` 接受一个可选的版本期望:省略它表示无条件的裸提供方变更;提供它则在后端的原子临界区内守护变更。 - `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录观测版本,以它从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。 -授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 Agent Note 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;拆分文件系统 seam 与事件门控两份 Agent Note 将其替换为此处描述的基于新鲜度的策略插件。) +授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap>`;`dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本决策最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs` 上;拆分文件系统 seam 与事件门控两份笔记将其替换为此处描述的基于新鲜度的策略插件。) 路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目作用域的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。 @@ -148,11 +148,11 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 **接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。约定应暴露显示元数据,而不要求消费方理解宿主路径。 -**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义,重新制造本 Agent Note 试图避免的耦合。 +**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义,重新制造本决策所避免的耦合。 **编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护手段是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地收敛——一个赢,另一个得到 `FS_STALE_VERSION`。 -**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 Agent Note 最初将其放在文件系统 seam 内部;拆分文件系统 seam Agent Note 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方约定只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。 +**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本决策最初将其放在文件系统 seam 内部;拆分文件系统 seam 笔记随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方约定只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。 **`resolve` 然后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再以单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端来说这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变成独立请求,使单次 `read` 变为两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观测约定不变。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml index c10a8a497c..6abc3ded93 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.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-06-18-session-surface.md -2026-06-18-session-surface.md: 73b3ab9080506edb9b0693c22a4c30544b506c11 -2026-06-18-session-surface.zh.md: 0c7ebbacd2f89da8012f3c08f1e4354a1d6c2e8e +2026-06-18-session-surface.md: fd48a4ee4e177b84a235b044b054e4ac266df8ba +2026-06-18-session-surface.zh.md: 86f9c5fdd5d711fb81e03e95329d316bfef29227 diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md index 73b3ab9080..fd48a4ee4e 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md @@ -68,6 +68,6 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d - **`packages/session/session-persistence-jsonl`**: No changes required. - **`packages/session/session-persistence`**: Abstract interface unchanged. -The surface is the foundation for future history manipulation. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically. +The surface is the foundation history manipulation ships on — dsh-compact's compaction rides it. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically. A `tool/result` replacement may rewrite exactly one current `tool/result` and must preserve every data field except `content`. Session acceptance enforces this rule together with positional range and cited source-event validation, independent of optional diagnostic plugins. diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md index 0c7ebbacd2..86f9c5fdd5 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md @@ -68,6 +68,6 @@ export type SurfaceOp = - **`packages/session/session-persistence-jsonl`**:无需改动。 - **`packages/session/session-persistence`**:抽象接口不变。 -Surface 是未来历史操纵的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽条目的 `sourceEventSeqs`——新事件在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start`、`compaction/end`)不进入 surface。回放以确定性方式保留该决策。 +Surface 是历史操纵赖以落地的基础——dsh-compact 的压缩就搭载于其上。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽条目的 `sourceEventSeqs`——新事件在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start`、`compaction/end`)不进入 surface。回放以确定性方式保留该决策。 一次 `tool/result` 替换只能改写当前的一个 `tool/result`,并且必须保留除 `content` 以外的每个数据字段。Session 接纳会与位置范围和引用的源事件校验一起强制这条规则,不依赖可选的诊断插件。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 702a9035d5..00cff75149 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.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-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 1d373317664f605e5164e2eb6980b8fa7c6b4e05 -2026-06-18-shared-persistence-write-coordinator.zh.md: 607f1fe68d1404a56c84848c8561cf8749daab0b +2026-06-18-shared-persistence-write-coordinator.md: c376bb4a209a250d33274dfd60531475354ba363 +2026-06-18-shared-persistence-write-coordinator.zh.md: 06a2dafc4d14c29bb7758fdc67d3f0f8136983e2 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 1d37331766..c376bb4a20 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -12,7 +12,7 @@ English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md) Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its stateful public methods (`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`) to it. Backend-owned metadata and revision listing bypass the coordinator. -Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including immutable logical inspection and the default preparation fallback through `load`. +Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The risk that a coordinator makes unusual backends fight an inheritance hierarchy is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including immutable logical inspection and the default preparation fallback through `load`. The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence. diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 607f1fe68d..06a2dafc4d 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -12,7 +12,7 @@ Status: implemented 将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其有状态的公开方法(`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`)委托给协调器。由后端拥有的元数据与修订版本列举会绕过协调器。 -组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 +组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。协调器让非常规后端与继承层级作斗争的风险由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index f906aa4336..2270bc6f5f 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.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-06-20-branded-ids.md -2026-06-20-branded-ids.md: 005706a12769a03e9c8ce66bf631ca0d75ade8b4 -2026-06-20-branded-ids.zh.md: b6b58a8b703d5637a6a5053543193bf9779489ac +2026-06-20-branded-ids.md: 78242226103fef0eac320cdfac0ccb82292fd9e8 +2026-06-20-branded-ids.zh.md: 613ef81d198f10ec70ca5cb019776e607f4a8a79 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md index 005706a127..7824222610 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md @@ -8,7 +8,7 @@ English | [中文](2026-06-20-branded-ids.zh.md) The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. It is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's shared `Agent.id`/`SessionId` (`callerToken = (exec) => exec.agent?.id` in `packages/bash/tool-bash/src/index.ts`) wearing a different seam-local name. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md). @@ -16,13 +16,13 @@ The bash **owner token** is the related sub-case: `BashExecRequest.owner?: strin ## Decision -A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. +A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The decision has three parts, all honoring the existing "not every string" policy. - **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId` does. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` Service Definition methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). - **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's shared `id` (`SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash Service Definition never imports `dsh-session`. (Rationale in the next section.) -- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. +- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the change and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. Illustrative shape (the factory pattern is identical to the three existing brands): @@ -52,11 +52,11 @@ The obvious shortcut is to type `owner` as `SessionId` directly — it always *i Kept deliberately narrow per the "not every string needs a brand" policy. Each of these is a plausible future brand, deferred with a reason, not a commitment: -- **`ModelId`** (`GenerateOptions.model`, the `LlmService` adapter-registry key) — a real cross-package lookup key (config → agent → llm → adapter); a reasonable next brand, left out only to keep this Agent Note's blast radius focused. +- **`ModelId`** (`GenerateOptions.model`, the `LlmService` adapter-registry key) — a real cross-package lookup key (config → agent → llm → adapter); a reasonable next brand, left out only to keep this decision's blast radius focused. - **`ToolName`** (the `ToolRegistry` key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand. - **`ErrorCode`** (`HarnessError.code`) — a closed vocabulary (`ABORTED`, `NO_ADAPTER`, …), not a per-instance id; better served by a string-literal union than a brand, if anything. - **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low. -- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string today. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what do we do on failure?) and belongs in its own Agent Note, not bundled into this type-only pass. +- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string today. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision, not bundled into this type-only change. ## Verification @@ -65,5 +65,5 @@ The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` a ## Consequences - **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (Service Definition + Service provider + Consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md) because both touch the session-id / owner-token boundary; `OwnerToken` stays distinct from the unified id for the decoupling reason above. -- **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This Agent Note does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id. -- **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this Agent Note errs toward the ids that are model-facing or used for access control. +- **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This decision does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id. +- **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this decision errs toward the ids that are model-facing or used for access control. diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index b6b58a8b70..613ef81d19 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -8,7 +8,7 @@ Status: implemented harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 -**缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 task id 是普通 `string`:`BashTask.id: string`(`packages/bash/bash/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/bash/bash/src/index.ts` 中的 `BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateTaskId`、`assertTaskAccess`、`packages/bash/tool-bash/src/index.ts` 中 `task_id` 的 schema 参数)。它由每执行器计数器生成——`packages/bash/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash task id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。这是用户询问的核心案例,并且它是面向模型的 id(模型会把 `task_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 +**缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 task id 是普通 `string`:`BashTask.id: string`(`packages/bash/bash/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/bash/bash/src/index.ts` 中的 `BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateTaskId`、`assertTaskAccess`、`packages/bash/tool-bash/src/index.ts` 中 `task_id` 的 schema 参数)。它由每执行器计数器生成——`packages/bash/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash task id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id(模型会把 `task_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined`(`packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/bash/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。 @@ -16,13 +16,13 @@ bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` ## 决策 -纯类型变更。Brand 是零开销 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。工作分三部分,全部遵循既有的「不是每个 string 都需要」策略。 +纯类型变更。Brand 是零开销 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。该决策分三部分,全部遵循既有的「不是每个 string 都需要」策略。 - **为 bash task id 加 brand。** 在 `packages/bash/bash/src/types.ts`(*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 仅依赖它就能为自己的 id 加 brand,而无需引入 `dsh-llm`(或 `dsh-session`)来获取 `Branded`。将其贯穿 `BashTask.id`、`BashExecutor` Service Definition 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时对计数器输出做一次 brand),以及 `dsh-tool-bash` 的校验/访问面(`validateTaskId` 返回 `BashTaskId`;`task_id` 在模型 string 到达的工具边界处被 brand)。 - **铸造独立的 `OwnerToken` brand。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 共享的 `id`(`SessionId`)cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash Service Definition 从不导入 `dsh-session`。(理由见下一节。) -- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map`、`Map`、`get(id: SessionId)`、`Map`、ACP 的 `SessionId` surface、协调器的 `Map`。这是 diff 中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。 +- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map`、`Map`、`get(id: SessionId)`、`Map`、ACP 的 `SessionId` surface、协调器的 `Map`。这是变更中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。 示意形状(工厂模式与已有的三个 brand 完全一致): @@ -52,11 +52,11 @@ export function OwnerToken(id: string): OwnerToken { 遵循「不是每个 string 都需要 brand」的策略,刻意保持窄范围。以下每项都是合理的未来 brand 候选,附带推迟理由而非承诺: -- **`ModelId`**(`GenerateOptions.model`,`LlmService` 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → 适配器);合理的下一个 brand,仅为控制本 Agent Note 的影响范围而暂不纳入。 +- **`ModelId`**(`GenerateOptions.model`,`LlmService` 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → 适配器);合理的下一个 brand,仅为控制本决策的影响范围而暂不纳入。 - **`ToolName`**(`ToolRegistry` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。 - **`ErrorCode`**(`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。 - **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。 -- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算「格式错误」?失败时怎么办?),应在独立 Agent Note 中处理,不应捆绑进这次纯类型变更。 +- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理,不应捆绑进这次纯类型变更。 ## 验证 @@ -65,5 +65,5 @@ export function OwnerToken(id: string): OwnerToken { ## 后果 - **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 -- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的*会话 id 只要仍是格式正确的 string,就和以前一样能通过类型检查器。本 Agent Note 不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 -- **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 Agent Note 倾向于面向模型或用于访问控制的 id。 +- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的*会话 id 只要仍是格式正确的 string,就和以前一样能通过类型检查器。本决策不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 +- **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本决策倾向于面向模型或用于访问控制的 id。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 32db924032..21c6e32e11 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.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-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: 71e74e753c39c8eddfe886d69ee9f28edff9ebab -2026-06-21-mandatory-app-attribution-headers.zh.md: 9cc2c178cd83f3f041282a0dac34b70f78bcc467 +2026-06-21-mandatory-app-attribution-headers.md: 12482ca80d19e5cd1e62b8860865db6cccea61ab +2026-06-21-mandatory-app-attribution-headers.zh.md: 43f356887948b88d34ba41d46ed1cd2f1c89a1e2 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 71e74e753c..12482ca80d 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -26,9 +26,9 @@ The immediate prompt came from OpenRouter's [App Attribution](https://openrouter Provider request attribution is mandatory at the LLM adapter boundary, using the standard `User-Agent` header only. The rule: every product LLM adapter sends a static, non-secret application identity on every provider HTTP request, and every adapter has tests proving that `User-Agent` reaches the wire (a mock server asserting received headers; for a library-backed adapter, the library's header hook feeding the same mock-server assertion). -Do **not** implement OpenRouter app attribution in this Agent Note. `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, and `X-OpenRouter-Categories` are OpenRouter-specific product-surface headers, not provider-neutral model-request attribution. They can be proposed later by an OpenRouter adapter or explicit OpenRouter mode, with its own privacy/product decision, tests, and docs. Until then, even requests pointed at OpenRouter send only the shared `User-Agent` attribution from this Agent Note. +OpenRouter app attribution is deliberately not implemented. `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, and `X-OpenRouter-Categories` are OpenRouter-specific product-surface headers, not provider-neutral model-request attribution. They can be proposed later by an OpenRouter adapter or explicit OpenRouter mode, with its own privacy/product decision, tests, and docs. Until then, even requests pointed at OpenRouter send only the shared `User-Agent` attribution from this decision. -The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attribution.ts`), not by individual adapters. `AppIdentity` contains only public product facts needed to build `User-Agent`, and the default `APP_IDENTITY` settles the values the proposal left open: +The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attribution.ts`), not by individual adapters. `AppIdentity` contains only public product facts needed to build `User-Agent`, and the default `APP_IDENTITY` values: - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant @@ -42,7 +42,7 @@ Wire mapping (`attributionHeaders`; header names lowercase in code - HTTP field |---|---| | All HTTP-based adapters | `User-Agent: {product}/{version} (+{url})` - the parenthesized `+url` comment stays within RFC 9110's conservative product/comment syntax. | | Direct DeepSeek endpoint | `User-Agent`; do not send OpenRouter-only headers unless DeepSeek documents an equivalent contract. | -| OpenRouter endpoints | `User-Agent` only for now. Do not send `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, or `X-OpenRouter-Categories` under this Agent Note. | +| OpenRouter endpoints | `User-Agent` only for now. Do not send `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, or `X-OpenRouter-Categories` under this decision. | | Future providers | `User-Agent` only unless a later provider-specific Agent Note accepts additional headers. Do not reuse `HTTP-Referer` by analogy. | Endpoint detection is not part of this Agent Note because no endpoint-specific mapping is accepted here. If OpenRouter support lands later, detection must be explicit: either a dedicated OpenRouter provider package or an explicit `provider: 'openrouter'` / `attributionTarget: 'openrouter'` config, not arbitrary path fragments or model names. @@ -55,13 +55,13 @@ The landed contract: - A shared helper (`attributionHeaders` / `userAgent`) constructs the app identity and the standard `User-Agent` value from package metadata, so adapters do not hand-copy version constants. - `dsh-llm-deepseek` sends the shared `User-Agent` on every request and its mock-server suite asserts the exact value. - `dsh-llm-pi-ai` sends the same `User-Agent` through pi-ai's `StreamOptions.headers` hook and its mock-server suite asserts the exact value. -- No adapter sends OpenRouter-specific attribution headers (`HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, `X-OpenRouter-Categories`) as part of this Agent Note. +- No adapter sends OpenRouter-specific attribution headers (`HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, `X-OpenRouter-Categories`) as part of this decision. - No app-attribution field carries secrets, local paths, session ids, prompt text, model output, user email, or per-user stable identifiers. - The adapter READMEs state the `User-Agent` attribution policy and explicitly avoid documenting OpenRouter app attribution as implemented behavior. ## Alternatives considered -**OpenRouter app attribution now.** Rejected for this Agent Note. Sending `HTTP-Referer` plus `X-OpenRouter-Title` would satisfy OpenRouter rankings, but those headers are a provider-specific product feature, not the provider-neutral model-request attribution this Agent Note is trying to standardize. Supporting them should be an explicit OpenRouter adapter/mode decision later, not hidden inside the first shared attribution helper. +**OpenRouter app attribution now.** Rejected for this decision. Sending `HTTP-Referer` plus `X-OpenRouter-Title` would satisfy OpenRouter rankings, but those headers are a provider-specific product feature, not the provider-neutral model-request attribution this decision standardizes. Supporting them should be an explicit OpenRouter adapter/mode decision later, not hidden inside the first shared attribution helper. **OpenRouter headers everywhere.** Rejected. It would treat a custom OpenRouter contract as a universal standard and send fields with misleading semantics to providers that did not ask for them. It also risks using `HTTP-Referer` as a generic app URL field even though standard HTTP already has `User-Agent` for product identity and `Referer` for a different browsing-context concept. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 9cc2c178cd..43f3568879 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -26,9 +26,9 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。 -本 Agent Note **不**实现 OpenRouter 应用归属。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本 Agent Note 定义的共享 `User-Agent` 归属。 +OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本决策定义的共享 `User-Agent` 归属。 -提供方无关的身份由 `dsh-llm`(`packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值: +提供方无关的身份由 `dsh-llm`(`packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 取值如下: - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 @@ -42,7 +42,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 |---|---| | 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 | | 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档化了等效约定,否则不发送 OpenRouter 特有头部。 | -| OpenRouter 端点 | 目前仅 `User-Agent`。本 Agent Note 下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | +| OpenRouter 端点 | 目前仅 `User-Agent`。本决策下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | | 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 Agent Note 接受额外头部。不要类比复用 `HTTP-Referer`。 | 端点检测不在本 Agent Note 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter,检测必须是显式的:要么是专门的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名称。 @@ -55,13 +55,13 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - 共享辅助函数(`attributionHeaders` / `userAgent`)从包元数据构建应用身份和标准 `User-Agent` 值,适配器无需手动复制版本常量。 - `dsh-llm-deepseek` 在每个请求上发送共享的 `User-Agent`,其 mock 服务器套件断言精确值。 - `dsh-llm-pi-ai` 通过 pi-ai 的 `StreamOptions.headers` 钩子发送相同的 `User-Agent`,其 mock 服务器套件断言精确值。 -- 本 Agent Note 下没有适配器发送 OpenRouter 特有的归属头部(`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title`、`X-OpenRouter-Categories`)。 +- 本决策下没有适配器发送 OpenRouter 特有的归属头部(`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title`、`X-OpenRouter-Categories`)。 - 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户的稳定标识符。 - 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现的行为。 ## 曾考虑的替代方案 -**现在就实现 OpenRouter 应用归属。** 本 Agent Note 否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特有的产品功能,不是本 Agent Note 试图标准化的提供方无关的模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在首个共享归属辅助函数中。 +**现在就实现 OpenRouter 应用归属。** 本决策否决。发送 `HTTP-Referer` 加 `X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特有的产品功能,不是本决策所标准化的提供方无关的模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在首个共享归属辅助函数中。 **向所有提供方发送 OpenRouter 头部。** 否决。这会把一项自定义的 OpenRouter 惯例当作通用标准,并向未要求这些字段的提供方发送语义误导的头部。还有风险将 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index 4147d8dc12..2824639954 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md -2026-06-24-web-capability-seam.md: 3893c9f9d45eac90c9ae381f9fa92ad8c7ae7644 -2026-06-24-web-capability-seam.zh.md: 70745c235d17e3cbf6152c2ef470283b0bad85f2 +2026-06-24-web-capability-seam.md: ccf04420425555055ce3810d25bf764db17322bb +2026-06-24-web-capability-seam.zh.md: 73f7d0d9b82c03d41bdc768f22b22316e1bc907f diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 3893c9f9d4..ccf0442042 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -327,10 +327,10 @@ Rejected for the seam. `prompt` turns fetch into LLM summarization and couples p - SSRF / private-network protection for `web_fetch`: block private, loopback, link-local, multicast, and otherwise non-public destinations so `web_fetch` is not an SSRF primitive. Doing it correctly is more than a URL-string check — it needs DNS-resolve-then-connect-to-the-validated-IP (to defeat DNS rebinding / TOCTOU), per-hop re-validation across redirects, and IPv6 edge handling (private ranges, IPv4-mapped addresses). Neither reference implementation surveyed does IP-level blocking (OpenCode does a prefix check then fetches; Claude Code relies on a centralized hostname blocklist plus a "private URLs will fail" prompt), so there is no implementation to copy and this is the harness's only SSRF defense — it warrants its own focused design/spike. Until it lands, `web_fetch` must only be enabled in deployments that cannot reach sensitive internal targets. - A `pdf` `WebFetchBody` kind: the `local-http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled. - Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently. -- Permission policy integration once the deferred permission system lands. +- Permission policy integration: the permission system now exists ([sandbox and approval](../feature/2026-07-06-sandbox.md), [web permission presets](../feature/2026-07-23-web-permission-and-approval.md)) but bundles only sandbox mode and approval policy; web permission policy remains unintegrated. - Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly. ## Open questions - Should product app packages probe web configuration at startup (treating `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, and `WEB_PROVIDER_AMBIGUOUS` as fatal when web is explicitly configured), or leave misconfiguration to surface at the first execution? -- Where should permission policy for public web access live once the deferred permission system lands: a dedicated web permission plugin on `tools/execute`, provider config, or both? +- Where should permission policy for public web access live in the shipped permission system ([sandbox and approval](../feature/2026-07-06-sandbox.md), [web permission presets](../feature/2026-07-23-web-permission-and-approval.md)): a dedicated web permission plugin on `tools/execute`, provider config, or both? diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index 70745c235d..73f7d0d9b8 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -329,10 +329,10 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 - `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。 - `pdf` `WebFetchBody` 类别:`local-http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。 - 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。 -- 推迟的权限系统落地后的权限策略集成。 +- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。 - `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。 ## 开放问题 - 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 和 `WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出? -- 推迟的权限系统落地后,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有? +- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有? diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index a8acbaf10e..c2d46de08f 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.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-06-26-file-context-as-event-gate.md -2026-06-26-file-context-as-event-gate.md: 353beb2498a310eee8cc794fa5edc22915fd6ad0 -2026-06-26-file-context-as-event-gate.zh.md: 37d7c1e4d971896a0e6ddeb42650635b8f755ca0 +2026-06-26-file-context-as-event-gate.md: 743c8f150a83f2452cb0ff672860b8462a56f762 +2026-06-26-file-context-as-event-gate.zh.md: cd79b44dd3f3b75c5e5addc5be10c02bd1ce0151 diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 353beb2498..743c8f150a 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -112,7 +112,7 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li ## Tool contract (`dsh-tool-fs`) -The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-fs-policy`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the fs-policy plugin requires it. The bare-provider fallback does not change the prompt stance. +The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-fs-policy`: the model is still told to read before overwriting or editing, and that requirement is the fs-policy plugin's, not the backend's. The bare-provider fallback does not change the prompt stance. `dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read rendering** (`read-render.ts`: `buildWindow` + `formatReadOutput`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, plus `STREAM_MIN_SIZE` in `read.ts`), which is the tool's rendering detail now that the tool owns the read. Those read-rendering types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index 37d7c1e4d9..cd79b44dd3 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -112,7 +112,7 @@ interface Events { ## 工具约定(`dsh-tool-fs`) -工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和提示词段落。提示词引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称「后端」要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变提示词立场。 +工具保留其面向模型的 schema(`read`/`write`/`edit`,逐字节不变)和提示词段落。提示词引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,而该要求来自 fs-policy 插件,并非后端。裸提供方回退不改变提示词立场。 `dsh-tool-fs` 获得从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**(`read-render.ts`:`buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为读取已由工具拥有。这些读取渲染类型和辅助函数移入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index 8201388c72..a1fac69cf4 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.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-06-30-bash-stdin-env-trusted-plugin-surface.md -2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 1c2cfc4257e93b2f2f126947e93b38aa2d4cd570 -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 050f604259033f20be8174754c32f624e9a0ce76 +2026-06-30-bash-stdin-env-trusted-plugin-surface.md: d8193b47ff16da7efb5f6bf1c3f34c0e1251572c +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 7f5bb62d92f27c49b0f7f262dc64dce39c89f2cd diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index 1c2cfc4257..d8193b47ff 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -6,7 +6,7 @@ English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md) ## Problem -The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This Agent Note adds those two inputs. +The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This change adds those two inputs. `stdin` and `env` do not create a new model capability because ordinary shell syntax already supplies both. Ambient credentials are protected by `dsh-bash-local`'s child-environment scrub, not by hiding these Service Definition fields; model tool arguments are static JSON and do not expand shell variables. The fields therefore serve trusted in-process callers, such as hook bridges, that need to pass structured input and `CLAUDE_*` variables without embedding them in model-visible shell text. See [defensive-patterns.md](../../../../docs/defensive-patterns.md) for the ambient-environment rule. diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index 050f604259..7f5bb62d92 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.bash` 能力 seam 后面有一个完善的命令执行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本 Agent Note 添加这两个输入。 +钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.bash` 能力 seam 后面有一个完善的命令执行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本次变更添加这两个输入。 `stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.md)。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index fab7f4d6d6..8916c347d9 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.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-06-30-event-domain-semantics.md -2026-06-30-event-domain-semantics.md: a3af693126b71c5844260f53c62b4ab4a28fe56f -2026-06-30-event-domain-semantics.zh.md: 1f6df779fdccb921861f6ea848e33d9cd866b027 +2026-06-30-event-domain-semantics.md: dbb3ef49979b7eeaa9a43b7db4a2d5a5839b5569 +2026-06-30-event-domain-semantics.zh.md: 93fe14adeaa47d276219a316ff1250a9982f5f14 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md index a3af693126..dbb3ef4997 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -12,7 +12,7 @@ The harness extends the agent loop through a Cordis event taxonomy (see [the mic - `agent/*` carries live runtime signals that hand a plugin the `Agent` handle. - `tools/*` carries the tool registry and execution pipeline. -Two problems motivated pinning the semantics down. First, several turn/step boundaries existed BOTH as a durable `SessionEvent` (`turn/start`, `turn/end`, `step/start`, `step/end`) AND as a mirrored `agent/*` emit (`agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`). A consumer had two sources of truth for the same fact, and every lifecycle change had to update both. Second, the upcoming Hooks subsystem needs ONE coherent, documented surface to subscribe to — a plugin author (and the Claude Code / Codex hook bridges built on top) must know, without reading the loop, whether to listen on a session event or an agent event, and why. +Two problems motivated pinning the semantics down. First, several turn/step boundaries existed BOTH as a durable `SessionEvent` (`turn/start`, `turn/end`, `step/start`, `step/end`) AND as a mirrored `agent/*` emit (`agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`). A consumer had two sources of truth for the same fact, and every lifecycle change had to update both. Second, the Hooks subsystem needs ONE coherent, documented surface to subscribe to — a plugin author (and the Claude Code / Codex hook bridges built on top) must know, without reading the loop, whether to listen on a session event or an agent event, and why. This vocabulary is the foundation for interception decisions, the durable `hook/*` log, and the Claude Code and Codex bridges. @@ -34,6 +34,6 @@ This vocabulary is the foundation for interception decisions, the durable `hook/ - Tests that observed boundaries via the removed emits now observe the durable `turn/start`/`turn/end`/`step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a *throwing turn-boundary emit listener* were deleted, because that code path no longer exists (there is no emit to throw from). Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved (or died) together. - The loop marks the step open (`stepOpen = true`) only after `append('step/start')` returns. Internal dispatch validation runs before the log push and may reject without opening a step; post-commit `session/event` observer failures are contained inside `Session.append`. The marker therefore represents exactly the committed boundary that owes a later `step/end`. - The full realization of this is [the simplification Agent Note "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (not a boundary mirror) stayed outside that Agent Note's scope and was removed by its own follow-up, [Remove the `agent/steering` mirror emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) — it mirrored the durable mid-turn steering `user/message`. -- The cordis events catalog (`docs/cordis-catalog/events.md`) is regenerated to drop the mirror events. +- The generated cordis event surface (the `docs/subsystems/` pages) no longer lists the mirror events. diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index 1f6df779fd..93fe14adea 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -12,7 +12,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) - `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。 - `tools/*` 承载工具注册表与执行流水线。 -两个问题促使我们固定语义。第一,若干轮次/步骤边界同时作为持久的 `SessionEvent`(`turn/start`、`turn/end`、`step/start`、`step/end`)和镜像的 `agent/*` emit(`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的钩子子系统需要一个连贯且有文档的订阅表面——插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须在不阅读循环代码的情况下知道应该监听会话事件还是 agent 事件,以及原因。 +两个问题促使我们固定语义。第一,若干轮次/步骤边界同时作为持久的 `SessionEvent`(`turn/start`、`turn/end`、`step/start`、`step/end`)和镜像的 `agent/*` emit(`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,钩子子系统需要一个连贯且有文档的订阅表面——插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须在不阅读循环代码的情况下知道应该监听会话事件还是 agent 事件,以及原因。 这套词汇是拦截决策、持久的 `hook/*` 日志,以及 Claude Code 和 Codex 桥接的基础。 @@ -34,6 +34,6 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) - 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们所锁定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的轮次边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 - 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 - 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的中途 steering `user/message`。 -- Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。 +- 生成的 cordis 事件表面(`docs/subsystems/` 各页)不再列出镜像事件。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index b242af54ee..759fecb3b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.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-05-prompt-variables-and-tool-guidance-ownership.md -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 199153741a5677e9f7b9bc6510f7a77b9791343e -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 988c128382fd8ff5a8a4742486860d931013ac80 +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 8f8b40a44314fc6add97842d48273e49927f92e2 +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 6fc71e2ab866822cee389c0c54efe1731847cbb7 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 199153741a..8f8b40a443 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -46,9 +46,9 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect - **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.) - **Inject the model name via the `agent/request` waterfall** — prompt text would be composed in two places and the earlier rendered persona could disagree with the final routed header. The request plugin that owns late routing must also own any earlier prompt claim about that model. -- **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this Agent Note cures. +- **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this decision cures. - **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. -- **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, the P2 disease again. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words. +- **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, reviving the hand-written-guidance-in-leaf-YAML drift. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words. - **Resolving the provider at `apply` time (a load-order requirement)** and **section-only subagent wording (lazily resolved at assemble)** — the alternatives to the provider-lifecycle events; both rejected in [the provider-lifecycle-events Agent Note](2026-07-05-subagent-provider-lifecycle-events.md). ## Out of scope diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 988c128382..6fc71e2ab8 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -46,9 +46,9 @@ Status: implemented - **循环自行组合一行 identity 文本**:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。) - **通过 `agent/request` waterfall 注入模型名称**:提示词文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在提示词中更早出现的声明。 -- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 Agent Note 要治愈的病症。 +- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本决策要治愈的病症。 - **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 -- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。 +- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。 - **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 ## 不在范围内 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index b85c732f6e..54935a491e 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: c971f411eb932cab29d7a52662a0e05a1f638daa -2026-07-05-reconstructable-requests.zh.md: b788f895cbb33f6ffa59b36430e81384a468f582 +2026-07-05-reconstructable-requests.md: c13e40b5b22e5d6fe19f6b5bd137db3a48350390 +2026-07-05-reconstructable-requests.zh.md: 746bcd37dabada949873d394776c1ca43eff4ea2 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index c971f411eb..c13e40b5b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -50,6 +50,6 @@ Like MiniCode, the conversation advances append-only and resets only when model- - Model-visible context uses logged message channels. `agent.inject()` and tool `additionalContexts` enter the inbox for a later claim, while `agent/pre-step` returns context that must settle with the current claimed batch. Each entered value is a durable sourced `user/message`, paid once and prefix-cached thereafter at the price of accumulating in history until compaction. - What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side. - `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel. -- Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. +- Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. - Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. - Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index b788f895cb..746bcd37da 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -50,6 +50,6 @@ Status: implemented - 模型可见上下文使用已记录消息通道。`agent.inject()` 与工具 `additionalContexts` 进入 inbox,等待后续领取;必须与当前已领取批次一起结算的上下文由 `agent/pre-step` 返回。每个进入步骤的值都是带来源的持久 `user/message`,只付出一次代价并在后续成为可缓存前缀,代价是会在历史中累积直至压缩。 - 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 - `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。 -- 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 +- 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index c85785a097..fa9304ec09 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.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-06-timeout-deadline-library.md -2026-07-06-timeout-deadline-library.md: 72f02208f601ff5514fe6ea0006ea922cdbd49b7 -2026-07-06-timeout-deadline-library.zh.md: c4da7f2028abc32d0c393dd1aeb3c59a4c05acec +2026-07-06-timeout-deadline-library.md: b7c7ac07ce0acec819fa7e88b32b3b6d624f3e6e +2026-07-06-timeout-deadline-library.zh.md: e4ec6b9a9e07b55faf9f44f2b99b765620e626c4 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 72f02208f6..b7c7ac07ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -98,12 +98,12 @@ The signal only *notifies*; termination is always the listener's job, and the li ## Consequences - `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The uniform SIGTERM→grace→SIGKILL kill is unchanged, and the Service Definition type `BashRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched. -- `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. This is the one deviation from the literal proposal shape (which passed `timeoutMs: 0` into `runBash`); an always-0 field read by nothing is dead weight under the per-file coverage gate. +- `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. An always-0 field read by nothing is dead weight under the per-file coverage gate. - web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`. - `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met). - Model streams now share one rearmable timer contract without turning a sliding idle interval into a total-call deadline or charging consumer think time. Adapters that can observe out-of-band transport activity may pulse an outstanding demand; suppressed activity remains invisible to the watchdog. The primitive still only notifies; adapter tests prove their transports observe its stable signal and terminate. -Out of scope, named to mark the boundary: `web_search` can gain an optional model-facing `timeout_ms` once its tool-schema/snapshot coverage is planned; future ripgrep-backed fs discovery tools can consume the same provider-owned deadline shape once they exist; a `tools/execute` waterfall middleware could arm a default deadline for every tool call by driving `exec.signal` — that would be a plugin that *consumes* this library and still only notifies, the hard kill remaining each capability's job. +Out of scope, named to mark the boundary: `web_search` can gain an optional model-facing `timeout_ms` once its tool-schema/snapshot coverage is planned; the ripgrep-backed fs discovery tools ([packaged ripgrep search](2026-08-01-packaged-ripgrep-search.md)) consume the same provider-owned deadline shape through `dsh-timeout-policy` and `exec.signal`; a `tools/execute` waterfall middleware could arm a default deadline for every tool call by driving `exec.signal` — that would be a plugin that *consumes* this library and still only notifies, the hard kill remaining each capability's job. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index c4da7f2028..e4ec6b9a9e 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -98,12 +98,12 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): ## 后果 - `runBash` 的结果不再独立锁存 `timedOut` 和 `aborted`;超时与用户中止在进程关闭前竞争时,现在报告单一的首个 abort 原因,而非两者同时为 true。统一的 SIGTERM→宽限期→SIGKILL 终止路径不变,Service Definition 类型 `BashRunResult` 保留两个布尔值(现在互斥),因此 `dsh-tool-bash` 的结果渲染不受影响。 -- `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。这是与字面提案形状(向 `runBash` 传入 `timeoutMs: 0`)的唯一偏差;一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 +- `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 - web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 - 模型流现在共享一个可重启的定时器约定,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。能够观察到带外传输活动的适配器可以对尚未结算的 demand 调用 `pulse()`;被屏蔽的活动对 watchdog 仍不可见。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。 -以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在实现后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 +以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](2026-08-01-packaged-ripgrep-search.md))通过 `dsh-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index 36484da590..4ac6aa8cdd 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.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-07-tool-call-timeout-policy.md -2026-07-07-tool-call-timeout-policy.md: d341a2c598765b6de4d2a7e242f38847e48f081b -2026-07-07-tool-call-timeout-policy.zh.md: 6d10b552b951f428f475323ba115007e39b66aaf +2026-07-07-tool-call-timeout-policy.md: f17834332b2e661b6811c2264d5e8de4e1642663 +2026-07-07-tool-call-timeout-policy.zh.md: 3be8614f209b2896f0754cadd868f6e66b78f834 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index d341a2c598..f17834332b 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -85,7 +85,7 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin `read`, `write`, `edit`, `todo_write`, `task_list`, and `task_kill` do not opt into tool-call timeout. `task_output` owns its bounded wait because a wait timeout is a successful live-status result, not a tool failure. -A future model-facing grep/glob tool can be implemented on top of `ctx.bash` without importing `@deepseek-ai/dsh-timeout`: it forwards `exec.signal` to `ctx.bash`, and declares its own `timeoutMs` (from its plugin's config) for the enforcer to apply. If bash-local's backend timeout becomes a problem for such a tool, the bash seam can later add a caller-owned-deadline mode; that is outside this cut. +A future model-facing grep/glob tool can be implemented on top of `ctx.bash` without importing `@deepseek-ai/dsh-timeout`: it forwards `exec.signal` to `ctx.bash`, and declares its own `timeoutMs` (from its plugin's config) for the enforcer to apply. If bash-local's backend timeout becomes a problem for such a tool, the bash seam can later add a caller-owned-deadline mode; that is a separate decision. ## Alternatives considered @@ -111,4 +111,3 @@ A future model-facing grep/glob tool can be implemented on top of `ctx.bash` wit - Multiple `tools/execute` listeners compose by ordinary Cordis waterfall order: a listener that calls `next()` wraps downstream listeners plus dispatch; one that returns without `next()` short-circuits them. A deployment combining timeout with a future retry/sandbox/metrics wrapper chooses semantics by registration order ("timeout covers the whole retry" vs "timeout covers each attempt"). - Opt-in by declaration is a deliberate misconfiguration risk: a tool can declare a `timeoutMs` without honoring `exec.signal`, and that tool will not stop on timeout. The registry awaits that non-quiescent body rather than racing it, while the plugin contract states that declaring a budget means cooperative; the web tools prove the pattern on tools that already forward the signal. - During the transition `bash` and the migrated web tools use different timeout paths on purpose: `TOOL_TIMEOUT` is the model-facing tool-call budget, while `BASH_TIMEOUT` remains the bash backend timeout used by bash and hooks. -- Deviation from the literal proposal, recorded per the implemented-Agent Note rule: the plugin package is `@deepseek-ai/dsh-timeout-policy` (not `tool-timeout`), signal replacement is in-place `exec.signal` mutation before `next()` (not `next({ ...exec, signal })`, which cordis ignores), and the per-tool budget is declared on the `ToolDefinition` (`timeoutMs`, set by the owning tool plugin from its config) rather than mapped by tool name in this plugin's config — so the enforcer is zero-config and a mistyped tool name is impossible. All three are described in `## Decision` above. diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index 6d10b552b9..3be8614f20 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -85,7 +85,7 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { `read`、`write`、`edit`、`todo_write`、`task_list` 和 `task_kill` 不加入工具调用超时。`task_output` 自己拥有有界等待,因为等待超时是成功的实时状态结果,而非工具失败。 -未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现而无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对这类工具造成问题,bash seam 可以后续添加调用方自有截止模式;这不在本次范围内。 +未来面向模型的 grep/glob 工具可以基于 `ctx.bash` 实现而无需导入 `@deepseek-ai/dsh-timeout`:它将 `exec.signal` 转发给 `ctx.bash`,并声明自己的 `timeoutMs`(来自其插件配置)供执行器应用。如果 bash-local 的后端超时对这类工具造成问题,bash seam 可以后续添加调用方自有截止模式;那是一项独立的决策。 ## 曾考虑的替代方案 @@ -111,4 +111,3 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { - 多个 `tools/execute` 监听器按普通 Cordis waterfall 顺序组合:调用 `next()` 的监听器包装下游监听器加分发;不调用 `next()` 直接返回的监听器短路它们。一个同时组合超时与未来重试/沙箱/指标包装器的部署通过注册顺序选择语义(「超时覆盖整个重试」vs「超时覆盖每次尝试」)。 - 按声明加入是一个有意的误配置风险:工具可以声明 `timeoutMs` 但不遵循 `exec.signal`,这样的工具在超时时不会停止。注册表会等待这个尚未完全停稳的工具体结束,而不是与它竞速;同时插件约定声明:声明预算意味着协作;web 工具在已转发信号的工具上验证了这一模式。 - 过渡期间 `bash` 和已迁移的 web 工具有意使用不同的超时路径:`TOOL_TIMEOUT` 是面向模型的工具调用预算,而 `BASH_TIMEOUT` 仍是 bash 和钩子使用的 bash 后端超时。 -- 与字面提案的偏差,按 implemented-Agent Note 规则记录:插件包为 `@deepseek-ai/dsh-timeout-policy`(而非 `tool-timeout`);信号替换是在 `next()` 之前就地修改 `exec.signal`(而非 `next({ ...exec, signal })`,Cordis 会忽略后者);逐工具预算声明在 `ToolDefinition` 上(`timeoutMs`,由拥有该工具的插件从其配置中设置),而非在本插件配置中按工具名映射——因此执行器是零配置的,拼错工具名不可能发生。以上三点均在上文 `## Decision` 中描述。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index aa1d2c9d6f..8bef07d042 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: fe09e1b83f4f0a9f867f78351f643b1bd26d7c9d -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 0279e0cf032928e96bad9da02340cae753a0927f +2026-07-10-single-file-executable-sdk-runtime-distribution.md: a45678c9bb5fcae340ff7134687890879f56c630 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f1fccc508471356dd6434da0e126ed38f15ed3ba diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index fe09e1b83f..a45678c9bb 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -76,7 +76,7 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp **jsonrpc-agent carrying the full closure dependencies.** The app bin would declare 53+ dependencies it never imports — a "packaging manifest" masquerading as real dependency relationships — and would force constraints to open two exceptions for it, cordis-in-dependencies and a files wildcard. With the closure manifest landing on the python-side manifest package, constraints needs no exception at all and the bin keeps the normal package shape isomorphic to acp-agent. -**An open plugin set (loading user plugins from disk).** This round ships a closed set; the PoC incidentally confirmed that on-disk ESM import outside the VFS works (through the `ctx.baseUrl` relative-path channel). It is listed as a future evolution, which must separately solve sharing the cordis instance inside the exe with external plugins. +**An open plugin set (loading user plugins from disk).** The shipped set is closed; the PoC incidentally confirmed that on-disk ESM import outside the VFS works (through the `ctx.baseUrl` relative-path channel). It is listed as a future evolution, which must separately solve sharing the cordis instance inside the exe with external plugins. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 0279e0cf03..f1fccc5084 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -76,7 +76,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个 **让 jsonrpc-agent 承担完整闭包依赖。** 应用入口将声明 53 个以上自身并不 `import()` 的依赖,使「打包 manifest」伪装成真实依赖关系,还会迫使 `constraints` 为其增加 `cordis-in-dependencies` 与 `files` 通配符两个例外。将闭包清单放在 Python 侧的 manifest 包后,`constraints` 不需要任何例外,`bin` 也能保持与 acp-agent 同构的正常包形状。 -**开放插件集(从磁盘加载用户插件)。** 本期采用封闭集;PoC 同时证实,可以通过 `ctx.baseUrl` 相对路径通道从 VFS 外的磁盘 `import()` ESM。该能力列为后续演进,届时还需解决外部插件与 exe 内 Cordis 实例的共享问题。 +**开放插件集(从磁盘加载用户插件)。** 交付的集合是封闭的;PoC 同时证实,可以通过 `ctx.baseUrl` 相对路径通道从 VFS 外的磁盘 `import()` ESM。该能力列为后续演进,届时还需解决外部插件与 exe 内 Cordis 实例的共享问题。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 03b17f5f33..91c9baf2a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md -2026-07-15-lsp-capability-seam.md: 65d547a51568cca43fd8b2887bfe9a61c512b34d -2026-07-15-lsp-capability-seam.zh.md: a9dcedaa80742bb338dbca4c0c532ca6e7cb2655 +2026-07-15-lsp-capability-seam.md: a461432fe5f218ea6c3ff3e031b52f7ebffbd7a5 +2026-07-15-lsp-capability-seam.zh.md: bfbd8d04f71b154d2833c86f2a6dc84d7e13fff4 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md index 65d547a515..a461432fe5 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -32,7 +32,7 @@ The prompt positions LSP as a precision aid: `Use search/read for ordinary navig The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` selects and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` validates model arguments and passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting. -The intended contract shape is: +The contract shape: ```ts import type { Branded } from '@deepseek-ai/dsh-brand' @@ -147,7 +147,7 @@ The provider trusts its configured server. Its filesystem visibility and process ## Alternatives considered -**Copy Claude Code's unified schema.** Its cursor operations validate the core use case, but symbols and call hierarchy need different arguments. Copying all nine operations would freeze speculative surface, so the proposal aligns only on the four semantic queries. +**Copy Claude Code's unified schema.** Its cursor operations validate the core use case, but symbols and call hierarchy need different arguments. Copying all nine operations would freeze speculative surface, so the seam aligns only on the four semantic queries. **Let providers register tools.** Loaded servers would then control model schema and prompts, preventing one stable contract across local and remote providers. diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index a9dcedaa80..bfbd8d04f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -32,7 +32,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 校验模型参数,并只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。 -预期约定如下: +约定如下: ```ts import type { Branded } from '@deepseek-ai/dsh-brand' @@ -147,7 +147,7 @@ interface LspToolInput { ## 备选方案 -**照搬 Claude Code 的统一 schema。** 它的光标操作验证了核心场景,但符号与调用层级需要不同参数。照搬九种操作会固化尚未验证的接口,因此本提案只对齐四种语义查询。 +**照搬 Claude Code 的统一 schema。** 它的光标操作验证了核心场景,但符号与调用层级需要不同参数。照搬九种操作会固化尚未验证的接口,因此该 seam 只对齐四种语义查询。 **允许提供方注册工具。** 已加载服务器会控制模型 schema 和提示词,无法在本地与远程提供方之间维持统一约定。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 4632c7e1ac..bf20616836 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: ebab36e66768f6216603923d2313e77eaf412ae1 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 308446e48772c3bb8d6e0b5851dd526af641593a +2026-07-19-gui-layering-and-rpc-protocol.md: f3aea8360ea1db9b29da01f143daf30cd969b0e5 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 27b5401960a31ee3cedf2366d3b7f4efc527d482 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index ebab36e667..f3aea8360e 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) -> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation combines HTTP uplink with the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md), while the browser object layer is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md). +> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation combines HTTP uplink with the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md), while the browser object layer is in the [web client architecture note](2026-07-19-gui-web-client-architecture.md). ## Problem @@ -25,7 +25,7 @@ Directories layer as follows: - `packages/host/*`: packages provide host-side capability only (representing the Node.js engineering core built on the existing harness plugin system), and additionally - the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below -- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md)): +- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md)): - **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table. - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. @@ -52,7 +52,7 @@ Direction discipline (every rule auditable from package deps): - `runtime → apiproxy` is one-way; apiproxy depends only on type definitions. - Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`). - `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency. -- Cross-package client imports use the `/client` subpath for plugin packages, and between plugin packages they are type-only — a cross-plugin value import is a build error at the tsdown purity gate (value cooperation goes through cordis services; the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md) owns the edge rules). +- Cross-package client imports use the `/client` subpath for plugin packages, and between plugin packages they are type-only — a cross-plugin value import is a build error at the tsdown purity gate (value cooperation goes through cordis services; the [client plugin loading note](2026-07-23-client-plugin-loading-model.md) owns the edge rules). TypeScript checks in **two aggregate programs** referenced by a solution root (`tsconfig.json` = solution; `tsconfig.host.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs ([topology](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md)). @@ -66,7 +66,7 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod | Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | | Carrier layer | `dsh-host-webserver` | Web-shape HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | | Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | -| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture RFC | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | +| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | | Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app | #### Naming rule @@ -218,11 +218,11 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh run` drives core directly | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | -| (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | +| IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | ## How to extend (operational checklists) -**Add a unary method (5 steps)**: ① add the method signature to the domain interface (parameters/return inline — this is the single source of truth); ② add one `RpcMethodMap` row; ③ add the request/value schema pair in `.schema.ts` (anchored `Wire>`); ④ add one handler `UNARY_ROUTES` row (the handler's Web carriage is in the web client architecture RFC); ⑤ implement in the impl (echo `request.rpcId`). On the client side, add the passthrough row to the `IApiClient`/`AbstractApiClient` domain method tables. +**Add a unary method (5 steps)**: ① add the method signature to the domain interface (parameters/return inline — this is the single source of truth); ② add one `RpcMethodMap` row; ③ add the request/value schema pair in `.schema.ts` (anchored `Wire>`); ④ add one handler `UNARY_ROUTES` row (the handler's Web carriage is in the web client architecture note); ⑤ implement in the impl (echo `request.rpcId`). On the client side, add the passthrough row to the `IApiClient`/`AbstractApiClient` domain method tables. **Add a frame type (3 steps)**: ① add a branch to the `MuxFrame`/`HostFrame` union (answerable frames must note the stable-rpcId semantics); ② add a frame-schema branch; ③ the consumers' fold/routing documented-default already covers unknown types — add an explicit branch as needed. diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 308446e487..27b5401960 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 -> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。 +> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md)。 ## Problem @@ -23,7 +23,7 @@ Status: implemented 目录按照如下分层: - `packages/host/*`: 包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含 - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 -- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载 RFC](2026-07-23-client-plugin-loading-model.md) 所有): +- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有): - **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。 - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 @@ -50,7 +50,7 @@ harness core packages ──────────────────┘ - `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。 - client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。 - `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。 -- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载 RFC](2026-07-23-client-plugin-loading-model.md) 所有)。 +- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有)。 TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。 @@ -64,7 +64,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. | 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | | 承载层 | `dsh-host-webserver` | Web 形态 HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | -| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构 RFC | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | +| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构笔记 | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | | 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app | #### 命名规则 @@ -216,11 +216,11 @@ export type ResponseValue = | `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 core | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | -| (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,约定/基类零改 | +| IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,约定/基类零改 | ## 怎么扩展(操作清单) -**加一个 unary 方法(5 步)**:①域接口加方法签名(参数/返回内联,这是唯一事实源);②`RpcMethodMap` 加一行;③`<域>.schema.ts` 加 request/value schema 对(锚 `Wire>`);④handler `UNARY_ROUTES` 加一行(handler 的 Web 承载见 Web 客户端架构 RFC);⑤impl 实现(回显 `request.rpcId`)。client 侧 `IApiClient`/`AbstractApiClient` 的域方法表同步加一行透传。 +**加一个 unary 方法(5 步)**:①域接口加方法签名(参数/返回内联,这是唯一事实源);②`RpcMethodMap` 加一行;③`<域>.schema.ts` 加 request/value schema 对(锚 `Wire>`);④handler `UNARY_ROUTES` 加一行(handler 的 Web 承载见 Web 客户端架构笔记);⑤impl 实现(回显 `request.rpcId`)。client 侧 `IApiClient`/`AbstractApiClient` 的域方法表同步加一行透传。 **加一个帧型(3 步)**:①`MuxFrame`/`HostFrame` union 加一支(可应答帧须注明 rpcId 稳定语义);②帧 schema 加一支;③消费端 fold/路由的 documented-default 已兜底未知型,按需加显式分支。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index f0cc648a10..269933c1a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 5e927405e9012b5a56d82da8fcf600ca4aa1bd5b -2026-07-19-gui-web-client-architecture.zh.md: 4294af099dce4e1345e625694a054661483f8d12 +2026-07-19-gui-web-client-architecture.md: 82b2f85708c423748954644d4991e2d54d42874a +2026-07-19-gui-web-client-architecture.zh.md: c37252d1db291cae11db2a615c9e4005ece717da diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 5e927405e9..82b2f85708 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-19-gui-web-client-architecture.zh.md) -> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol RFC](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. +> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol note](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. ## Problem @@ -30,13 +30,13 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon ## The client cordis tree and the loading chain -The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dshClient` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `