From 82637524a3bf4192ac449d6be034a131f991e371 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 14:58:38 +0800 Subject: [PATCH 01/33] fix(snapshot): stabilize refresh volatiles --- ...table-snapshot-refresh-volatiles.i18n.yaml | 6 + ...07-27-stable-snapshot-refresh-volatiles.md | 29 +++++ ...27-stable-snapshot-refresh-volatiles.zh.md | 29 +++++ .../support/acp-snapshot/README.i18n.yaml | 6 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/suite.ts | 85 ++++++++++++++- .../support/acp-snapshot/tests/suite.spec.ts | 103 ++++++++++++++++++ 8 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml new file mode 100644 index 0000000000..44c713719c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.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-07-27-stable-snapshot-refresh-volatiles.md +2026-07-27-stable-snapshot-refresh-volatiles.md: e6bff6ef4d20b431cee86d16df863a7c7b138e02 +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 33acbbc0504ca1b61495cbb95a0f6dec28dfa0df diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md new file mode 100644 index 0000000000..e6bff6ef4d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -0,0 +1,29 @@ +# Agent Note: Stable snapshot refresh volatiles + +Status: implemented + +English | [中文](2026-07-27-stable-snapshot-refresh-volatiles.zh.md) + +## Problem + +ACP snapshot comparison normalizes generated UUIDs, cwd aliases, spill locators, embedded event times, and omitted-byte counts, but refresh write-back persisted the fresh raw values. A behaviorally unchanged refresh therefore rewrote fixtures with new randomness or host-specific path spellings even though the comparison contract considered both logs equal. + +## Decision + +Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. After existing record alignment, it recursively compares fresh and existing leaves through their normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. + +Object fields align by key. Array elements align only when all corresponding arrays have the same length; otherwise the fresh array wins. Records must retain the same type, and strings remain atomic leaves. Existing packed-chunk timing alignment and inserted-title handling remain separate because they align logical events rather than values inside one record. + +## Alternatives considered + +**Use deterministic UUIDs and spill filenames in snapshot deployments.** Replacing production randomness would weaken the security shape under test or require test-only behavior in storage and approval implementations. + +**Commit normalized fixtures.** Tokenized session logs would stop being raw replay inputs and would cause a broad fixture migration unrelated to the write-back defect. + +**Preserve a whole record when its normalized form is unchanged.** This is simpler but churns a random field whenever another field in the same record changes semantically. Leaf-level preservation keeps those decisions independent. + +## Consequences + +Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: changed record types, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data. + +Focused unit coverage pins recursive object/array behavior, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md new file mode 100644 index 0000000000..33acbbc050 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 稳定快照刷新中的易变值 + +Status: implemented + +[English](2026-07-27-stable-snapshot-refresh-volatiles.md) | 中文 + +## 问题 + +ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别名、spill locator、嵌入的事件时间和省略字节数,但刷新写回会持久化本次生成的原始值。因此,即使比较契约将两份日志视为相等,一次行为未发生变化的刷新仍会用新的随机值或宿主特有的路径写法改写 fixture(测试前置数据)。 + +## 决策 + +刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。现有记录完成对齐后,系统基于归一化后的记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 + +对象字段按键对齐。只有所有对应数组长度相同时,才对齐其元素;否则以本次生成的数组为准。记录必须保持同一类型,字符串始终作为不可拆分的叶节点。现有的打包分片计时对齐与插入标题处理仍保持独立,因为它们对齐的是逻辑事件,而非单条记录内的值。 + +## 考虑过的替代方案 + +**在快照部署中使用确定性的 UUID 和 spill 文件名。** 替换生产环境使用的随机性会削弱测试所要验证的安全属性,或者要求存储与审批实现引入仅用于测试的行为。 + +**提交归一化后的 fixture。** token 化的会话日志将不再是原始回放输入,并会引发与写回缺陷无关的大范围 fixture 迁移。 + +**当整条记录的归一化形式未变时保留整条记录。** 这种做法更简单,但同一记录中的另一个字段发生语义变化时,也会改写其中的随机字段。按叶节点保留可使这些决策彼此独立。 + +## 后果 + +重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录类型发生变化、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。 + +聚焦的单元测试固定了递归处理对象与数组的行为、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index baabe0d898..753d54450d 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: d35872e5bb06be88dc5999bfa1800083b2fbbf3c -README.zh.md: a706b6db5408538c578cb2a1cfc3aa99804a930d +# pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md +README.md: 682ab478a2e7453fa18769de93ff88a9a5316317 +README.zh.md: 8e67b9fbe3b6f66df303a1f4bbfaf1de46b64a8f diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index d35872e5bb..682ab478a2 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -9,7 +9,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh reuses normalized-equivalent leaves from aligned existing records while fresh semantic values remain authoritative; it also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index a706b6db54..8e67b9fbe3 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -9,7 +9,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 - **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会在对齐现有可变事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录;新分片碎片数组仍为权威数据。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会从已对齐的现有记录复用规范化后等价的叶值,而新生成的语义值仍为权威数据;它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index be5b6a02de..b36b3dff9f 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -510,13 +510,78 @@ function preservePackedMemberTimes( row.data.dt = gaps } +/** Whether a parsed JSON value is a non-array object. */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +/** + * Reuse existing leaves whose normalized values equal the fresh values. + * Objects merge by key; arrays merge only when their positions still align. + */ +function preserveNormalizedVolatiles( + fresh: unknown, + existing: unknown, + normalizedFresh: unknown, + normalizedExisting: unknown, +): unknown { + if ( + Array.isArray(fresh) + && Array.isArray(existing) + && Array.isArray(normalizedFresh) + && Array.isArray(normalizedExisting) + ) { + if ( + fresh.length !== existing.length + || fresh.length !== normalizedFresh.length + || fresh.length !== normalizedExisting.length + ) return fresh + return fresh.map((value, index) => preserveNormalizedVolatiles( + value, + existing[index], + normalizedFresh[index], + normalizedExisting[index], + )) + } + if ( + isRecord(fresh) + && isRecord(existing) + && isRecord(normalizedFresh) + && isRecord(normalizedExisting) + ) { + return Object.fromEntries(Object.entries(fresh).map(([key, value]) => [ + key, + Object.hasOwn(existing, key) + && Object.hasOwn(normalizedFresh, key) + && Object.hasOwn(normalizedExisting, key) + ? preserveNormalizedVolatiles( + value, + existing[key], + normalizedFresh[key], + normalizedExisting[key], + ) + : value, + ])) + } + return Object.is(normalizedFresh, normalizedExisting) ? existing : fresh +} + +/** Normalize one aligned record with the same contract used by fixture comparison. */ +function normalizedRefreshRecord( + record: Record, + context: NormalizeContext, +): Record { + return JSON.parse(normalizeSessionLog(`${JSON.stringify(record)}\n`, context)) as Record +} + /** * Rewrite a fresh replay-produced log so repeated refreshes do not churn * volatile fixture fields. Meaningful event payloads come from `fresh`; the - * existing fixture lends session ids, cwd, creation times, logical event - * times, and hook durations where the record shape still matches. Packed - * timing envelopes expand for alignment, so packing does not shift later - * records; fresh fragment arrays remain authoritative. + * existing fixture lends normalized-equivalent values, including ids, paths, + * creation/event times, spill locators, and hook durations, where records + * still align. Packed timing envelopes expand for alignment, so packing does + * not shift later records; fresh semantic values and fragment arrays remain + * authoritative. * * @param fresh The newly harvested session JSONL. * @param existing The committed fixture JSONL being refreshed. @@ -528,10 +593,11 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement for (const { from, to } of replacements) stable = stable.split(from).join(to) const existingRecords = logicalRecords(parseJsonlRecords(existing)) const records = parseJsonlRecords(stable) + const context = fixtureContext(existing) let existingIndex = 0 let previousEventTime: unknown for (let i = 0; i < records.length; i++) { - const record = records[i] as Record + let record = records[i] as Record const existingRecord = existingRecords[existingIndex] const memberCount = packedTimes(record)?.length ?? 1 const insertedTitle = record.type === 'session/title' && existingRecord?.type !== 'session/title' @@ -540,6 +606,15 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement if (typeof previousEventTime !== 'number') throw new Error('acp-snapshot: inserted title has no preceding event time') record.time = previousEventTime } else { + if (memberCount === 1 && existingRecord !== undefined && existingRecord.type === record.type) { + record = preserveNormalizedVolatiles( + record, + existingRecord, + normalizedRefreshRecord(record, context), + normalizedRefreshRecord(existingRecord, context), + ) as Record + records[i] = record + } preservePackedMemberTimes(record, existingRecords.slice(existingIndex, existingIndex + memberCount)) preserveFixtureVolatiles(record, existingRecord) existingIndex += memberCount diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index e021bc31d9..e2b5ae8419 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -592,4 +592,107 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n')) }) + + it('preserves normalized volatile fields while accepting fresh semantic fields', () => { + const freshApprovalId = '11111111-1111-4111-8111-111111111111' + const existingApprovalId = '22222222-2222-4222-8222-222222222222' + const freshSpill = '/tmp/dsh-acp-snap-012345678/session-111111111111/222222222222-bash.txt' + const existingSpill = '/tmp/dsh-acp-snap-012345678/session-aaaaaaaaaaaa/bbbbbbbbbbbb-bash.txt' + const freshEventRead = [ + 'Session main — title', + 'Target event seq 4:', + '```json', + '{', + ' "time": 1785000000000,', + ' "data": {}', + '}', + '```', + '', + `(Omitted 40000 bytes. Full formatted result stored at: ${freshSpill}. Use read with offset/limit, or grep this path to search within it.)`, + ].join('\n') + const existingEventRead = freshEventRead + .replace('1785000000000', '1784000000000') + .replace('40000 bytes', '30000 bytes') + .replace(freshSpill, existingSpill) + const fresh = [ + '{"type":"session","id":"same","createdAt":200,"cwd":"/old"}', + JSON.stringify({ + type: 'approval/asked', + seq: 1, + time: 22, + data: { + id: freshApprovalId, + outcome: 'fresh', + aliases: [freshApprovalId, 'fresh'], + resized: [freshApprovalId, 'new'], + shape: { shared: freshApprovalId, added: true }, + }, + }), + JSON.stringify({ + type: 'tool/result', + seq: 2, + time: 23, + data: { + spill: `Full formatted result stored at: ${freshSpill}. Use read with offset/limit, or grep this path to search within it.`, + path: '/private/old/result.txt', + eventRead: freshEventRead, + }, + }), + '', + ].join('\n') + const existing = [ + '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}', + JSON.stringify({ + type: 'approval/asked', + seq: 1, + time: 11, + data: { + id: existingApprovalId, + outcome: 'stale', + aliases: [existingApprovalId, 'stale'], + resized: [existingApprovalId], + shape: { shared: existingApprovalId }, + }, + }), + JSON.stringify({ + type: 'tool/result', + seq: 2, + time: 12, + data: { + spill: `Full formatted result stored at: ${existingSpill}. Use read with offset/limit, or grep this path to search within it.`, + path: '/old/result.txt', + eventRead: existingEventRead, + }, + }), + '', + ].join('\n') + + const output = stabilizeRefreshLog(fresh, existing, []).trim().split('\n') + .map(line => JSON.parse(line) as Record) + expect(output).toEqual([ + { type: 'session', id: 'same', createdAt: 100, cwd: '/old' }, + { + type: 'approval/asked', + seq: 1, + time: 11, + data: { + id: existingApprovalId, + outcome: 'fresh', + aliases: [existingApprovalId, 'fresh'], + resized: [freshApprovalId, 'new'], + shape: { shared: existingApprovalId, added: true }, + }, + }, + { + type: 'tool/result', + seq: 2, + time: 12, + data: { + spill: `Full formatted result stored at: ${existingSpill}. Use read with offset/limit, or grep this path to search within it.`, + path: '/old/result.txt', + eventRead: existingEventRead, + }, + }, + ]) + }) }) From 5eca318cdc1ade876476cb3fad75a89626457227 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 15:25:50 +0800 Subject: [PATCH 02/33] fix(snapshot): preserve volatile id correlations --- ...table-snapshot-refresh-volatiles.i18n.yaml | 4 +- ...07-27-stable-snapshot-refresh-volatiles.md | 8 +- ...27-stable-snapshot-refresh-volatiles.zh.md | 8 +- .../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/suite.ts | 140 +++++++++++++++++- .../support/acp-snapshot/tests/suite.spec.ts | 98 ++++++++++++ 8 files changed, 249 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index 44c713719c..804bdadf20 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.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-27-stable-snapshot-refresh-volatiles.md -2026-07-27-stable-snapshot-refresh-volatiles.md: e6bff6ef4d20b431cee86d16df863a7c7b138e02 -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 33acbbc0504ca1b61495cbb95a0f6dec28dfa0df +2026-07-27-stable-snapshot-refresh-volatiles.md: 0fd38cabd103657410a468b49e30ab17aad9affb +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: aa2b129f4e1cc5286ff17067fe910dfc969838a1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md index e6bff6ef4d..0fd38cabd1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -12,7 +12,9 @@ ACP snapshot comparison normalizes generated UUIDs, cwd aliases, spill locators, Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. After existing record alignment, it recursively compares fresh and existing leaves through their normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. -Object fields align by key. Array elements align only when all corresponding arrays have the same length; otherwise the fresh array wins. Records must retain the same type, and strings remain atomic leaves. Existing packed-chunk timing alignment and inserted-title handling remain separate because they align logical events rather than values inside one record. +Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log. + +Object fields align by key. Array elements align only when all corresponding arrays have the same length; otherwise the fresh array wins. Strings remain atomic leaves. Existing packed-chunk timing alignment and inserted-title handling remain separate because they align logical events rather than values inside one record. ## Alternatives considered @@ -24,6 +26,6 @@ Object fields align by key. Array elements align only when all corresponding arr ## Consequences -Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: changed record types, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data. +Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data. -Focused unit coverage pins recursive object/array behavior, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. +Focused unit coverage pins recursive object/array behavior, correlated IDs, ambiguous-layout fallback, conflicting mappings, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index 33acbbc050..aa2b129f4e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -12,7 +12,9 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。现有记录完成对齐后,系统基于归一化后的记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 -对象字段按键对齐。只有所有对应数组长度相同时,才对齐其元素;否则以本次生成的数组为准。记录必须保持同一类型,字符串始终作为不可拆分的叶节点。现有的打包分片计时对齐与插入标题处理仍保持独立,因为它们对齐的是逻辑事件,而非单条记录内的值。 +复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用规范化字符串复用。 + +对象字段按键对齐。只有所有对应数组长度相同时,才对齐其元素;否则以本次生成的数组为准。字符串始终作为不可拆分的叶节点。现有的打包分片计时对齐与插入标题处理仍保持独立,因为它们对齐的是逻辑事件,而非单条记录内的值。 ## 考虑过的替代方案 @@ -24,6 +26,6 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 后果 -重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录类型发生变化、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。 +重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。 -聚焦的单元测试固定了递归处理对象与数组的行为、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 +聚焦的单元测试固定了递归处理对象与数组的行为、关联 ID、有歧义布局时的回退、映射冲突、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 753d54450d..ba3825dfbe 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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/support/acp-snapshot/README.md -README.md: 682ab478a2e7453fa18769de93ff88a9a5316317 -README.zh.md: 8e67b9fbe3b6f66df303a1f4bbfaf1de46b64a8f +README.md: 6d9226d5fcf44f41b672cd8c1a8e3bdad0623162 +README.zh.md: 56a79472b1730778aff497efeefd66f0fb625bb9 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 682ab478a2..6d9226d5fc 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -9,7 +9,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh reuses normalized-equivalent leaves from aligned existing records while fresh semantic values remain authoritative; it also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 8e67b9fbe3..56a79472b1 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -9,7 +9,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 - **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会从已对齐的现有记录复用规范化后等价的叶值,而新生成的语义值仍为权威数据;它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。只有完整逻辑记录布局对齐且易变字符串替换形成双射时,刷新才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index b36b3dff9f..3f5d87efe0 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -524,6 +524,7 @@ function preserveNormalizedVolatiles( existing: unknown, normalizedFresh: unknown, normalizedExisting: unknown, + stringMappings: ReadonlyMap, ): unknown { if ( Array.isArray(fresh) @@ -541,6 +542,7 @@ function preserveNormalizedVolatiles( existing[index], normalizedFresh[index], normalizedExisting[index], + stringMappings, )) } if ( @@ -559,10 +561,21 @@ function preserveNormalizedVolatiles( existing[key], normalizedFresh[key], normalizedExisting[key], + stringMappings, ) : value, ])) } + if ( + typeof fresh === 'string' + && typeof existing === 'string' + && typeof normalizedFresh === 'string' + && normalizedFresh === normalizedExisting + ) { + return stringMappings.get(JSON.stringify([normalizedFresh, fresh])) === existing + ? existing + : fresh + } return Object.is(normalizedFresh, normalizedExisting) ? existing : fresh } @@ -574,14 +587,124 @@ function normalizedRefreshRecord( return JSON.parse(normalizeSessionLog(`${JSON.stringify(record)}\n`, context)) as Record } +/** + * Add normalized-equivalent string replacements to a bijection. + * Structural differences are fresh-owned and therefore contribute no mapping. + */ +function collectNormalizedStringMappings( + fresh: unknown, + existing: unknown, + normalizedFresh: unknown, + normalizedExisting: unknown, + forward: Map, + reverse: Map, +): boolean { + if ( + Array.isArray(fresh) + && Array.isArray(existing) + && Array.isArray(normalizedFresh) + && Array.isArray(normalizedExisting) + ) { + if ( + fresh.length !== existing.length + || fresh.length !== normalizedFresh.length + || fresh.length !== normalizedExisting.length + ) return true + return fresh.every((value, index) => collectNormalizedStringMappings( + value, + existing[index], + normalizedFresh[index], + normalizedExisting[index], + forward, + reverse, + )) + } + if ( + isRecord(fresh) + && isRecord(existing) + && isRecord(normalizedFresh) + && isRecord(normalizedExisting) + ) { + return Object.entries(fresh).every(([key, value]) => + !Object.hasOwn(existing, key) + || !Object.hasOwn(normalizedFresh, key) + || !Object.hasOwn(normalizedExisting, key) + || collectNormalizedStringMappings( + value, + existing[key], + normalizedFresh[key], + normalizedExisting[key], + forward, + reverse, + )) + } + if ( + typeof fresh !== 'string' + || typeof existing !== 'string' + || typeof normalizedFresh !== 'string' + || normalizedFresh !== normalizedExisting + || fresh === existing + ) return true + const freshKey = JSON.stringify([normalizedFresh, fresh]) + const existingKey = JSON.stringify([normalizedFresh, existing]) + const mappedExisting = forward.get(freshKey) + const mappedFresh = reverse.get(existingKey) + if ( + mappedExisting !== undefined && mappedExisting !== existing + || mappedFresh !== undefined && mappedFresh !== fresh + ) return false + forward.set(freshKey, existing) + reverse.set(existingKey, fresh) + return true +} + +/** + * Build a log-wide bijection for normalized-equivalent strings. + * Any unexplained record mismatch or conflicting replacement disables reuse. + */ +function normalizedStringMappings( + records: Record[], + existingRecords: Record[], + context: NormalizeContext, +): Map | undefined { + const forward = new Map() + const reverse = new Map() + let existingIndex = 0 + for (const record of records) { + const existingRecord = existingRecords[existingIndex] + const memberCount = packedTimes(record)?.length ?? 1 + if (record.type === 'session/title' && existingRecord?.type !== 'session/title') continue + if (memberCount > 1) { + const existingMembers = existingRecords.slice(existingIndex, existingIndex + memberCount) + if ( + existingMembers.length !== memberCount + || existingMembers.some(member => member.type !== 'assistant/chunk') + ) return undefined + } else { + if (existingRecord === undefined || existingRecord.type !== record.type) return undefined + if (!collectNormalizedStringMappings( + record, + existingRecord, + normalizedRefreshRecord(record, context), + normalizedRefreshRecord(existingRecord, context), + forward, + reverse, + )) return undefined + } + existingIndex += memberCount + } + return existingIndex === existingRecords.length ? forward : undefined +} + /** * Rewrite a fresh replay-produced log so repeated refreshes do not churn * volatile fixture fields. Meaningful event payloads come from `fresh`; the * existing fixture lends normalized-equivalent values, including ids, paths, - * creation/event times, spill locators, and hook durations, where records - * still align. Packed timing envelopes expand for alignment, so packing does - * not shift later records; fresh semantic values and fragment arrays remain - * authoritative. + * creation/event times, spill locators, and hook durations, only when the + * complete record layout aligns and volatile strings form a consistent + * bijection. Ambiguous layouts or mappings keep fresh strings. Packed timing + * envelopes expand for alignment, so packing does not shift later records; + * fresh semantic values and fragment arrays remain authoritative. * * @param fresh The newly harvested session JSONL. * @param existing The committed fixture JSONL being refreshed. @@ -594,6 +717,7 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement const existingRecords = logicalRecords(parseJsonlRecords(existing)) const records = parseJsonlRecords(stable) const context = fixtureContext(existing) + const stringMappings = normalizedStringMappings(records, existingRecords, context) let existingIndex = 0 let previousEventTime: unknown for (let i = 0; i < records.length; i++) { @@ -606,12 +730,18 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement if (typeof previousEventTime !== 'number') throw new Error('acp-snapshot: inserted title has no preceding event time') record.time = previousEventTime } else { - if (memberCount === 1 && existingRecord !== undefined && existingRecord.type === record.type) { + if ( + stringMappings !== undefined + && memberCount === 1 + && existingRecord !== undefined + && existingRecord.type === record.type + ) { record = preserveNormalizedVolatiles( record, existingRecord, normalizedRefreshRecord(record, context), normalizedRefreshRecord(existingRecord, context), + stringMappings, ) as Record records[i] = record } diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index e2b5ae8419..fbbd2c18e9 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -695,4 +695,102 @@ describe('stabilizeRefreshLog', () => { }, ]) }) + + it('preserves one correlated volatile id through a consistent log-wide mapping', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const existingId = '22222222-2222-4222-8222-222222222222' + const fresh = [ + '{"type":"session","id":"same","createdAt":200,"cwd":"/old"}', + JSON.stringify({ type: 'approval/asked', data: { id: freshId } }), + JSON.stringify({ type: 'approval/decided', data: { id: freshId, outcome: 'allowed-once' } }), + '', + ].join('\n') + const existing = [ + '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}', + JSON.stringify({ type: 'approval/asked', data: { id: existingId } }), + JSON.stringify({ type: 'approval/decided', data: { id: existingId, outcome: 'rejected' } }), + '', + ].join('\n') + + expect(stabilizeRefreshLog(fresh, existing, [])).toBe([ + '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}', + JSON.stringify({ type: 'approval/asked', data: { id: existingId } }), + JSON.stringify({ type: 'approval/decided', data: { id: existingId, outcome: 'allowed-once' } }), + '', + ].join('\n')) + }) + + it('keeps fresh correlated ids when record alignment is structurally ambiguous', () => { + const firstFreshId = '11111111-1111-4111-8111-111111111111' + const secondFreshId = '22222222-2222-4222-8222-222222222222' + const existingId = '33333333-3333-4333-8333-333333333333' + const fresh = [ + '{"type":"session","id":"same","createdAt":200,"cwd":"/old"}', + JSON.stringify({ type: 'approval/asked', data: { id: firstFreshId } }), + JSON.stringify({ type: 'approval/asked', data: { id: secondFreshId } }), + JSON.stringify({ type: 'approval/decided', data: { id: firstFreshId } }), + JSON.stringify({ type: 'approval/decided', data: { id: secondFreshId } }), + '', + ].join('\n') + const existing = [ + '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}', + JSON.stringify({ type: 'approval/asked', data: { id: existingId } }), + JSON.stringify({ type: 'approval/decided', data: { id: existingId } }), + '', + ].join('\n') + + const ids = stabilizeRefreshLog(fresh, existing, []).trim().split('\n').slice(1) + .map(line => (JSON.parse(line) as { data: { id: string } }).data.id) + expect(ids).toEqual([firstFreshId, secondFreshId, firstFreshId, secondFreshId]) + }) + + it('keeps fresh ids when existing records remain unmatched', () => { + const freshId = '11111111-1111-4111-8111-111111111111' + const existingId = '22222222-2222-4222-8222-222222222222' + const fresh = [ + '{"type":"session","id":"same","createdAt":200,"cwd":"/old"}', + JSON.stringify({ type: 'approval/asked', data: { id: freshId } }), + '', + ].join('\n') + const existing = [ + '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}', + JSON.stringify({ type: 'approval/asked', data: { id: existingId } }), + JSON.stringify({ type: 'approval/decided', data: { id: existingId } }), + '', + ].join('\n') + + const output = stabilizeRefreshLog(fresh, existing, []).trim().split('\n') + .map(line => JSON.parse(line) as Record) + expect(output[1]).toEqual({ type: 'approval/asked', data: { id: freshId } }) + }) + + it.each([ + { + name: 'one fresh id would map to two existing ids', + fresh: ['a', 'b', 'b', 'a'], + existing: ['x', 'y', 'x', 'y'], + }, + { + name: 'two fresh ids would map to one existing id', + fresh: ['a', 'b'], + existing: ['x', 'x'], + }, + ])('keeps fresh ids when $name', ({ fresh: freshNames, existing: existingNames }) => { + const ids = { + a: '11111111-1111-4111-8111-111111111111', + b: '22222222-2222-4222-8222-222222222222', + x: '33333333-3333-4333-8333-333333333333', + y: '44444444-4444-4444-8444-444444444444', + } as const + const types = ['approval/asked', 'approval/asked', 'approval/decided', 'approval/decided'] + const log = (names: string[]): string => [ + '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}', + ...names.map((name, index) => JSON.stringify({ type: types[index], data: { id: ids[name as keyof typeof ids] } })), + '', + ].join('\n') + + const outputIds = stabilizeRefreshLog(log(freshNames), log(existingNames), []).trim().split('\n').slice(1) + .map(line => (JSON.parse(line) as { data: { id: string } }).data.id) + expect(outputIds).toEqual(freshNames.map(name => ids[name as keyof typeof ids])) + }) }) From d28527b91fca94c3bf4dee19ebcf739afecc762a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 27 Jul 2026 15:46:40 +0800 Subject: [PATCH 03/33] fix(snapshot): preserve fresh cwd aliases --- ...table-snapshot-refresh-volatiles.i18n.yaml | 4 +- ...07-27-stable-snapshot-refresh-volatiles.md | 4 +- ...27-stable-snapshot-refresh-volatiles.zh.md | 4 +- .../headless-agent/tests/headless.snapshot.ts | 8 +-- .../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/suite.ts | 38 ++++++++---- .../support/acp-snapshot/tests/suite.spec.ts | 58 +++++++++++++++---- 9 files changed, 88 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml index 804bdadf20..f2d73ddf1f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.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-27-stable-snapshot-refresh-volatiles.md -2026-07-27-stable-snapshot-refresh-volatiles.md: 0fd38cabd103657410a468b49e30ab17aad9affb -2026-07-27-stable-snapshot-refresh-volatiles.zh.md: aa2b129f4e1cc5286ff17067fe910dfc969838a1 +2026-07-27-stable-snapshot-refresh-volatiles.md: e2e951cd9f78b319a701a3e60afba48786633f03 +2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 55302b509e28520f90f6cd820e4962be014318cc diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md index 0fd38cabd1..e2e951cd9f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md @@ -10,7 +10,7 @@ ACP snapshot comparison normalizes generated UUIDs, cwd aliases, spill locators, ## Decision -Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. After existing record alignment, it recursively compares fresh and existing leaves through their normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. +Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log. @@ -28,4 +28,4 @@ Object fields align by key. Array elements align only when all corresponding arr Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data. -Focused unit coverage pins recursive object/array behavior, correlated IDs, ambiguous-layout fallback, conflicting mappings, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. +Focused unit coverage pins recursive object/array behavior, correlated IDs, ambiguous-layout fallback, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md index aa2b129f4e..55302b509e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.zh.md @@ -10,7 +10,7 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 ## 决策 -刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。现有记录完成对齐后,系统基于归一化后的记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 +刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。 复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用规范化字符串复用。 @@ -28,4 +28,4 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别 重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。 -聚焦的单元测试固定了递归处理对象与数组的行为、关联 ID、有歧义布局时的回退、映射冲突、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 +聚焦的单元测试固定了递归处理对象与数组的行为、关联 ID、有歧义布局时的回退、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。 diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 6852fed20b..8d3c94d144 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -160,6 +160,7 @@ describe('headless stream-json snapshots', () => { const children = logs.filter(log => typeof log.header.parentSession === 'string') .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) const actualSessions = [parent, ...children] + const actualContext = contextFromLogs(actualSessions.map(log => log.content)) if (refreshing) { const harvested = actualSessions.map((log): HarvestedLog => ({ id: String(log.header.id), @@ -176,12 +177,11 @@ describe('headless stream-json snapshots', () => { if (existing === undefined || file === undefined) { throw new Error(`headless snapshot has no fixture for persisted log ${index}`) } - const stable = stabilizeRefreshLog(actual.content, existing, replacements) + const stable = stabilizeRefreshLog(actual.content, existing, replacements, actualContext) await writeFile(file, stable) return stable })) } - const actualContext = contextFromLogs(actualSessions.map(log => log.content)) const expectedContext = contextFromLogs(expectedSessions) for (const [index, actual] of actualSessions.entries()) { const expected = expectedSessions[index] @@ -354,6 +354,7 @@ describe('headless stream-json snapshots', () => { expect(logs).toHaveLength(1) const actual = logs[0] if (actual === undefined) throw new Error('headless PTY snapshot did not persist its session') + const actualContext = contextFromLogs([actual.content]) if (refreshing) { const harvested: HarvestedLog = { id: String(actual.header.id), @@ -361,10 +362,9 @@ describe('headless stream-json snapshots', () => { content: actual.content, } const replacements = refreshFixtureReplacements([harvested], [expectedSession]) - expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements) + expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext) await writeFile(ptySessionFixture, expectedSession) } - const actualContext = contextFromLogs([actual.content]) const expectedContext = contextFromLogs([expectedSession]) expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext))) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index ba3825dfbe..11f7a71ae4 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/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/support/acp-snapshot/README.md -README.md: 6d9226d5fcf44f41b672cd8c1a8e3bdad0623162 -README.zh.md: 56a79472b1730778aff497efeefd66f0fb625bb9 +README.md: 1f8027405b815ea661aa34aa53a512230fa284e8 +README.zh.md: ef1d0a60ba583ed350c3211f59be2444e3fadfac diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 6d9226d5fc..1f8027405b 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -9,7 +9,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 56a79472b1..ef1d0a60ba 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -9,7 +9,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 - **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。只有完整逻辑记录布局对齐且易变字符串替换形成双射时,刷新才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 3f5d87efe0..8df233a2a0 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -664,13 +664,16 @@ function collectNormalizedStringMappings( */ function normalizedStringMappings( records: Record[], + freshRecords: Record[], existingRecords: Record[], - context: NormalizeContext, + freshContext: NormalizeContext, + existingContext: NormalizeContext, ): Map | undefined { const forward = new Map() const reverse = new Map() let existingIndex = 0 - for (const record of records) { + for (let recordIndex = 0; recordIndex < records.length; recordIndex++) { + const record = records[recordIndex] as Record const existingRecord = existingRecords[existingIndex] const memberCount = packedTimes(record)?.length ?? 1 if (record.type === 'session/title' && existingRecord?.type !== 'session/title') continue @@ -685,8 +688,8 @@ function normalizedStringMappings( if (!collectNormalizedStringMappings( record, existingRecord, - normalizedRefreshRecord(record, context), - normalizedRefreshRecord(existingRecord, context), + normalizedRefreshRecord(freshRecords[recordIndex] as Record, freshContext), + normalizedRefreshRecord(existingRecord, existingContext), forward, reverse, )) return undefined @@ -709,15 +712,28 @@ function normalizedStringMappings( * @param fresh The newly harvested session JSONL. * @param existing The committed fixture JSONL being refreshed. * @param replacements Cross-log literal replacements from {@link refreshFixtureReplacements}. + * @param freshContext The harvested run's ids, cwd, and every cwd alias. * @returns The stabilized JSONL content to write back. */ -export function stabilizeRefreshLog(fresh: string, existing: string, replacements: FixtureReplacement[]): string { +export function stabilizeRefreshLog( + fresh: string, + existing: string, + replacements: FixtureReplacement[], + freshContext: NormalizeContext, +): string { + const freshRecords = parseJsonlRecords(fresh) let stable = fresh for (const { from, to } of replacements) stable = stable.split(from).join(to) const existingRecords = logicalRecords(parseJsonlRecords(existing)) const records = parseJsonlRecords(stable) - const context = fixtureContext(existing) - const stringMappings = normalizedStringMappings(records, existingRecords, context) + const existingContext = fixtureContext(existing) + const stringMappings = normalizedStringMappings( + records, + freshRecords, + existingRecords, + freshContext, + existingContext, + ) let existingIndex = 0 let previousEventTime: unknown for (let i = 0; i < records.length; i++) { @@ -739,8 +755,8 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement record = preserveNormalizedVolatiles( record, existingRecord, - normalizedRefreshRecord(record, context), - normalizedRefreshRecord(existingRecord, context), + normalizedRefreshRecord(freshRecords[i] as Record, freshContext), + normalizedRefreshRecord(existingRecord, existingContext), stringMappings, ) as Record records[i] = record @@ -864,12 +880,12 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ] const primary = (result.sessionLogs[0] as HarvestedLog).content await writeFile(join(dir, outputFixtureFiles[0] as string), scrub( - REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements) : primary, + REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) : primary, )) for (let i = 1; i < result.sessionLogs.length; i++) { const child = (result.sessionLogs[i] as HarvestedLog).content await writeFile(join(dir, outputFixtureFiles[i] as string), scrub( - REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements) : child, + REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) : child, )) } if (RECORDING) { diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index fbbd2c18e9..7b26dc305b 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -43,6 +43,15 @@ const AGENT = { } const REPLAY_DIR = fileURLToPath(new URL('./fixtures/suite', import.meta.url)) + +function stabilize( + fresh: string, + existing: string, + replacements: Parameters[2] = [], + freshContext: Parameters[3] = fixtureContext(fresh), +): string { + return stabilizeRefreshLog(fresh, existing, replacements, freshContext) +} const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.url)) // Replay pins explicit header classes; recording covers the default fallback. @@ -477,7 +486,7 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n') - expect(stabilizeRefreshLog(fresh, existing, [])).toBe([ + expect(stabilize(fresh, existing)).toBe([ '{"type":"session","id":"same","createdAt":100}', '{"type":"reasoning-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}', '{"type":"assistant/message","seq":5,"time":104,"data":{}}', @@ -497,7 +506,7 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n') - expect(stabilizeRefreshLog(fresh, existing, [])).toBe([ + expect(stabilize(fresh, existing)).toBe([ '{"type":"session","id":"same","createdAt":100}', '{"type":"text-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}', '', @@ -522,10 +531,9 @@ describe('stabilizeRefreshLog', () => { time, data: {}, })) - const output = stabilizeRefreshLog( + const output = stabilize( `${JSON.stringify({ type: 'session', id: 'same', createdAt: 200 })}\n${JSON.stringify(freshRow)}\n`, `${JSON.stringify({ type: 'session', id: 'same', createdAt: 100 })}\n${existingRows.map(row => JSON.stringify(row)).join('\n')}\n`, - [], ).trim().split('\n').map(line => JSON.parse(line) as Record) expect(output[1]).toStrictEqual({ ...freshRow, time0: expectedTime0 }) @@ -550,7 +558,7 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n') - expect(stabilizeRefreshLog(fresh, existing, [])).toBe([ + expect(stabilize(fresh, existing)).toBe([ '{"type":"session","id":"same","createdAt":100}', '{"type":"turn/start","seq":0,"time":11}', '{"type":"user/message","seq":1,"time":12}', @@ -579,7 +587,7 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n') - expect(stabilizeRefreshLog(fresh, existing, [ + expect(stabilize(fresh, existing, [ { from: 'new-parent', to: 'old-parent' }, { from: 'new-child', to: 'old-child' }, { from: '/new', to: '/old' }, @@ -667,7 +675,7 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n') - const output = stabilizeRefreshLog(fresh, existing, []).trim().split('\n') + const output = stabilize(fresh, existing).trim().split('\n') .map(line => JSON.parse(line) as Record) expect(output).toEqual([ { type: 'session', id: 'same', createdAt: 100, cwd: '/old' }, @@ -696,6 +704,34 @@ describe('stabilizeRefreshLog', () => { ]) }) + it('normalizes fresh cwd aliases before reusing existing paths', () => { + const freshCwd = String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snap-cwd-new` + const freshAlias = String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp-snap-cwd-new` + const existingCwd = String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snap-cwd-old` + const fresh = [ + JSON.stringify({ type: 'session', id: 'same', createdAt: 200, cwd: freshCwd }), + JSON.stringify({ type: 'tool/result', data: { path: `${freshAlias}\\result.txt` } }), + '', + ].join('\n') + const existing = [ + JSON.stringify({ type: 'session', id: 'same', createdAt: 100, cwd: existingCwd }), + JSON.stringify({ type: 'tool/result', data: { path: `${existingCwd}\\result.txt` } }), + '', + ].join('\n') + const freshContext = { ...fixtureContext(fresh), cwdAliases: [freshAlias] } + + expect(stabilize( + fresh, + existing, + [{ from: freshCwd, to: existingCwd }], + freshContext, + )).toBe([ + JSON.stringify({ type: 'session', id: 'same', createdAt: 100, cwd: existingCwd }), + JSON.stringify({ type: 'tool/result', data: { path: `${existingCwd}\\result.txt` } }), + '', + ].join('\n')) + }) + it('preserves one correlated volatile id through a consistent log-wide mapping', () => { const freshId = '11111111-1111-4111-8111-111111111111' const existingId = '22222222-2222-4222-8222-222222222222' @@ -712,7 +748,7 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n') - expect(stabilizeRefreshLog(fresh, existing, [])).toBe([ + expect(stabilize(fresh, existing)).toBe([ '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}', JSON.stringify({ type: 'approval/asked', data: { id: existingId } }), JSON.stringify({ type: 'approval/decided', data: { id: existingId, outcome: 'allowed-once' } }), @@ -739,7 +775,7 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n') - const ids = stabilizeRefreshLog(fresh, existing, []).trim().split('\n').slice(1) + const ids = stabilize(fresh, existing).trim().split('\n').slice(1) .map(line => (JSON.parse(line) as { data: { id: string } }).data.id) expect(ids).toEqual([firstFreshId, secondFreshId, firstFreshId, secondFreshId]) }) @@ -759,7 +795,7 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n') - const output = stabilizeRefreshLog(fresh, existing, []).trim().split('\n') + const output = stabilize(fresh, existing).trim().split('\n') .map(line => JSON.parse(line) as Record) expect(output[1]).toEqual({ type: 'approval/asked', data: { id: freshId } }) }) @@ -789,7 +825,7 @@ describe('stabilizeRefreshLog', () => { '', ].join('\n') - const outputIds = stabilizeRefreshLog(log(freshNames), log(existingNames), []).trim().split('\n').slice(1) + const outputIds = stabilize(log(freshNames), log(existingNames)).trim().split('\n').slice(1) .map(line => (JSON.parse(line) as { data: { id: string } }).data.id) expect(outputIds).toEqual(freshNames.map(name => ids[name as keyof typeof ids])) }) From 3aeee114656c0570ff0f045e26d0407b2e8399f7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 16:27:23 +0800 Subject: [PATCH 04/33] chore: ignore worktree directories --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c488fa5a91..4b8ebe60cc 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ python/**/.pytest_cache/ apps/web/dist/ .artifacts/ .playwright-mcp/ +.worktrees/ +worktrees/ +.agents/worktrees/ From e30f642e37a8901c6f9575f97dfd03c721c4acc7 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 27 Jul 2026 17:23:36 +0800 Subject: [PATCH 05/33] refactor(todos): update TodoRow styling and logic, add IconChecklistOutline16, and enhance AssistantMarkdown rendering --- apps/web/tests/todo-display.snapshot.ts | 2 +- .../src/client/chat/AssistantMarkdown.tsx | 8 ++++- .../src/client/toolviews/todo-row.module.css | 35 ++++++++++++++----- .../src/client/toolviews/todo-row.tsx | 22 ++++++++---- .../tests/coverage-tails.spec.tsx | 14 ++++++++ .../client/ui-primitives/src/icons/index.tsx | 10 ++++++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +-- 7 files changed, 77 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index 3116bf4242..86603b676c 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -158,7 +158,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn "text": "○浏览器验收", }, ], - "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本", + "row": "更新任务清单1/3 已完成 · 实现 fixture 样本", "rowState": "ok", } `) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 0e91afcc07..2e2f8a6678 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -40,13 +40,19 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) { 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 + // between tool groups — skip the shell unless something visible remains. + const hasVisible = streaming === true + || interrupted === true + || blocks.some((block) => block.kind !== 'tool-call') + if (!hasVisible) return null return (
{blocks.map((block, i) => { switch (block.kind) { case 'text': return case 'reasoning': return - // Tool-call heads render as tool rows in the chat view's grouping pass. case 'tool-call': return null default: return } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index ff4068d49c..dd32d56b01 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -1,29 +1,44 @@ -/* todo_write plan-update row: title + progress summary on one line. */ +/* todo_write plan-update row: ToolRow chrome (figma 780:53675) — + [16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */ .row { display: flex; align-items: center; - gap: 8px; height: 24px; min-width: 0; cursor: pointer; border-radius: 6px; - font-size: 13px; } .row:hover { background: var(--dsw-alias-interactive-bg-hover); } -.badge { +.leading { flex: none; - color: var(--dsw-alias-state-business-primary); + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); } .title { flex: none; - font-weight: 510; - color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-primary-dimmed); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); } .summary { @@ -32,11 +47,15 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); } .err { flex: none; + margin-left: 8px; color: var(--dsw-alias-state-error-primary); font-size: 11px; + line-height: 16px; } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index 353e7a5441..a47322b614 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -3,13 +3,13 @@ // hole like the bash sample (a product registration, not a sample). The row // summarizes the written list (counts + active item) from the call args; the // durable list itself renders in the TodoPanel above the composer, so the -// row stays one line. +// row stays one line. Chrome matches ToolRow (figma 780:53675). import type { KeyboardEvent } from 'react' import type { Context } from 'cordis' -import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' -import { toolRowModel } from '../contract/tool-call-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './todo-row.module.css' /** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */ @@ -40,6 +40,17 @@ function summarize(argsRaw: string): string | null { : head } +/** Leading-slot state substitution matches ToolRow / bash: icon yields to the + * state semantic while running or failed; ok keeps the checklist glyph. */ +function leadingFor(state: ToolRowState) { + switch (state) { + case 'running': return + case 'error': return + case 'stopped': return + default: return + } +} + /** One-line plan update row (click opens the raw args in details). Non-ok * execution states keep the generic row's dot semantics — a cancelled call * wrote no todo/write, so it must not read as a completed update. */ @@ -64,10 +75,9 @@ export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { onClick={openDetails} onKeyDown={openFromKeyboard} > - {model.state === 'ok' - ? - : } + {leadingFor(model.state)} 更新任务清单 + {summary} {model.state === 'error' && failed} {model.state === 'stopped' && 已中断} diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 18ac9a2891..d5cbf9ec42 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -60,6 +60,20 @@ describe('tails', () => { expect(stopped.getByText('已停止')).toBeTruthy() }) + it('AssistantMarkdown skips the root shell when only tool-call heads remain', () => { + // Tool heads are drawn by ChatView's tool groups; an empty root between + // groups is layout noise (no text, no pulse, no interrupted marker). + const empty = render( + , + ) + expect(empty.container.firstChild).toBeNull() + const blank = render() + expect(blank.container.firstChild).toBeNull() + }) + it('a settled others-variant row renders the sparkle icon in the leading slot', () => { const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4c2083bae1..5af9567b0d 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -653,6 +653,16 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_checklist_outline_16 (figma extract): two rings + two list bars. */ +export const IconChecklistOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + +) + /** ic_ds_List_Pen_outline_16 */ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 281124b8d5..c6303bc28e 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (43 deepsuite + 12 figma extracts)', () => { - expect(iconNames.length).toBe(55) + it('exports the full P-I set (43 deepsuite + 13 figma extracts)', () => { + expect(iconNames.length).toBe(56) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => { From bede841ec71863378770f741f28359499d0c149e Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 27 Jul 2026 17:42:54 +0800 Subject: [PATCH 06/33] fix: cr --- .../client/ui-conversation/src/client/chat/AssistantMarkdown.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 2e2f8a6678..52fdc14217 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -53,6 +53,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea switch (block.kind) { case 'text': return case 'reasoning': return + // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null default: return } From b21acea0ce26b1a53c8c503a055a0a8f9c55484d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:36:37 +0000 Subject: [PATCH 07/33] chore(deps): bump actions/configure-pages from 5 to 6 Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6. - [Release notes](https://github.com/actions/configure-pages/releases) - [Commits](https://github.com/actions/configure-pages/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/configure-pages dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/docs-pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 281e931c50..036dcb268a 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -45,7 +45,7 @@ jobs: - name: Configure Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Verify and build documentation env: From 5e2d37b19cd06df07081be92d310cc5c5135386f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:36:41 +0000 Subject: [PATCH 08/33] chore(deps): bump actions/upload-pages-artifact from 4 to 5 Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4 to 5. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/docs-pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 281e931c50..34a29c1293 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -53,7 +53,7 @@ jobs: run: pnpm run doc-sync - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: website/.dist From cdf78e92775c08d52e0f48a6b0c803c1c25e9a6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:36:49 +0000 Subject: [PATCH 09/33] chore(deps): bump actions/setup-python from 6 to 6.3.0 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 6.3.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v6...v6.3.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/build-exe-for-python-sdk.yml | 4 ++-- .github/workflows/ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index b119a548b8..a112267f4d 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -93,7 +93,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6.3.0 with: python-version: '3.10' @@ -133,7 +133,7 @@ jobs: node-version: 24 cache: pnpm - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6.3.0 with: python-version: '3.10' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aecff76a7d..6c3697f3b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -323,7 +323,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6.3.0 with: python-version: '3.10' cache: pip From 669fe2415e2f4124bfeda070fe99babe8f8ae9a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:36:55 +0000 Subject: [PATCH 10/33] chore(deps-dev): update hatchling requirement in /python/sdk Updates the requirements on [hatchling](https://github.com/pypa/hatch) to permit the latest version. - [Release notes](https://github.com/pypa/hatch/releases) - [Commits](https://github.com/pypa/hatch/compare/hatchling-v1.24.0...hatchling-v1.30.1) --- updated-dependencies: - dependency-name: hatchling dependency-version: 1.30.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- python/sdk-runtime/pyproject.toml | 2 +- python/sdk/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index e04b9e6728..6db7595297 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling>=1.24.0"] +requires = ["hatchling>=1.30.1"] build-backend = "hatchling.build" [project] diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index b0f1acf5f2..eeef355e90 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling>=1.24.0"] +requires = ["hatchling>=1.30.1"] build-backend = "hatchling.build" [project] From 79b5d570e7cc98d3273c1dd7c7ccb977c8ca6eeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:37:12 +0000 Subject: [PATCH 11/33] chore(deps-dev): bump typescript in /native/landlock-run Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.3. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3) --- updated-dependencies: - dependency-name: typescript dependency-version: 6.0.3 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- native/landlock-run/package.json | 2 +- native/landlock-run/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/native/landlock-run/package.json b/native/landlock-run/package.json index f516588f17..307ce07227 100644 --- a/native/landlock-run/package.json +++ b/native/landlock-run/package.json @@ -25,6 +25,6 @@ "node-addon-landlock-run": "workspace:*", "@types/node": "^24.10.0", "tsx": "^4.20.6", - "typescript": "^5.9.3" + "typescript": "^6.0.3" } } diff --git a/native/landlock-run/pnpm-lock.yaml b/native/landlock-run/pnpm-lock.yaml index 88b1b3df00..6072f39940 100644 --- a/native/landlock-run/pnpm-lock.yaml +++ b/native/landlock-run/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^4.20.6 version: 4.23.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 packages/entry: optionalDependencies: @@ -210,8 +210,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true @@ -340,6 +340,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - typescript@5.9.3: {} + typescript@6.0.3: {} undici-types@7.18.2: {} From 9879842f3eb1ec171d04b90cfcd9902f94c9176c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:37:24 +0000 Subject: [PATCH 12/33] chore(deps-dev): bump @types/node in /native/landlock-run Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.13.2 to 26.0.1. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- native/landlock-run/package.json | 2 +- native/landlock-run/pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/native/landlock-run/package.json b/native/landlock-run/package.json index f516588f17..05598694b9 100644 --- a/native/landlock-run/package.json +++ b/native/landlock-run/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "node-addon-landlock-run": "workspace:*", - "@types/node": "^24.10.0", + "@types/node": "^26.0.1", "tsx": "^4.20.6", "typescript": "^5.9.3" } diff --git a/native/landlock-run/pnpm-lock.yaml b/native/landlock-run/pnpm-lock.yaml index 88b1b3df00..dd911c8077 100644 --- a/native/landlock-run/pnpm-lock.yaml +++ b/native/landlock-run/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@types/node': - specifier: ^24.10.0 - version: 24.13.2 + specifier: ^26.0.1 + version: 26.0.1 node-addon-landlock-run: specifier: workspace:* version: link:packages/entry @@ -192,8 +192,8 @@ packages: cpu: [x64] os: [win32] - '@types/node@24.13.2': - resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} @@ -215,8 +215,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} snapshots: @@ -298,9 +298,9 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@types/node@24.13.2': + '@types/node@26.0.1': dependencies: - undici-types: 7.18.2 + undici-types: 8.3.0 esbuild@0.28.1: optionalDependencies: @@ -342,4 +342,4 @@ snapshots: typescript@5.9.3: {} - undici-types@7.18.2: {} + undici-types@8.3.0: {} From a5ed3a5cfc584ceb761b855a98a1126086dde69a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:02:43 +0800 Subject: [PATCH 13/33] refactor(dev-infra): narrow change scope report --- ...-27-explicit-change-scope-report.i18n.yaml | 4 +- ...2026-07-27-explicit-change-scope-report.md | 8 +- ...6-07-27-explicit-change-scope-report.zh.md | 8 +- .agents/skills/dsh-code-review/SKILL.md | 2 +- .agents/skills/dsh-doc-standards/SKILL.md | 2 +- .agents/skills/dsh-pre-push-checks/SKILL.md | 4 +- scripts/change-scope.spec.ts | 130 ++---------------- scripts/change-scope.ts | 90 ++---------- 8 files changed, 39 insertions(+), 209 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml index 1c39083a6e..b7072336ea 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md -2026-07-27-explicit-change-scope-report.md: 2cce567940a142ed1f4699f4dc67322ed69565e9 -2026-07-27-explicit-change-scope-report.zh.md: cc08df3ee4f3d681bc4ae8b2b0eab3588dfe1a73 +2026-07-27-explicit-change-scope-report.md: ed09ffc44252e1e571d50537b3471cad8d68d8f9 +2026-07-27-explicit-change-scope-report.zh.md: e13092395d6708b1ec030d13365853a5eaecba55 diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md index 2cce567940..ed09ffc442 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md @@ -12,13 +12,13 @@ An incorrect range undermines evidence selection because it can omit affected pa ## Decision -The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes; the repository root, branch, upstream, and every path are decoded as strict UTF-8. An invalid value aborts the report before output instead of substituting characters or collapsing distinct values. +The root `change-scope` command requires `--base `, accepts `--head ` with `HEAD` as the default, and writes one versioned JSON report. It resolves both inputs to commits with ambiguity detection and requires one merge base before rendering. The report records the repository root without normalizing legal path whitespace, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes; the repository root and every path are decoded as strict UTF-8. An invalid value aborts the report instead of substituting characters or collapsing distinct values. Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Every Git probe disables configured filesystem monitors and optional lock-taking; diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible. The command never guesses or fetches a base, queries a hosting provider, or selects tests. Each calling workflow verifies current remote or stack state, supplies the base explicitly, and uses the factual report as input to semantic review or evidence selection. -Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, a configured filesystem monitor remaining unexecuted, distinct non-UTF-8 POSIX paths and branch or upstream names failing without partial output, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting. +Focused temporary-repository tests cover explicit and stacked refs, every dirty layer, legal path whitespace, strict path decoding, inert probes, invalid refs, the deterministic schema, and unchanged refs, index, config, and status after reporting. ## Alternatives considered @@ -30,9 +30,11 @@ Focused temporary-repository tests cover a fresh branch tracking `origin/master` **Generate required tests from changed paths.** Paths cannot establish behavior reached through configuration, dynamic loading, subprocesses, workers, built artifacts, or providers. Evidence selection remains judgment under the pre-push workflow. +**Report current branch and upstream and maintain a parallel human renderer.** Callers already verify branch and base state before invocation, no consumer uses those fields, and formatted prose duplicates the JSON schema without improving path completeness. + ## Consequences -The explicit input makes an incorrect base possible but visible: both input refs and all three resolved commit IDs appear in either output form. Callers pay the small cost of verifying and fetching the live base before running the command. +The explicit input makes an incorrect base possible but visible: both input refs and all three resolved commit IDs appear in the report. Callers pay the small cost of verifying and fetching the live base before running the command. The string schema deliberately cannot represent non-UTF-8 path bytes. A repository containing them must rename those paths before it can produce a report, preserving exact scope instead of returning a lossy one. diff --git a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md index cc08df3ee4..e13092395d 100644 --- a/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分;仓库根目录、分支、上游和每条路径都以严格 UTF-8 解码。遇到无效值时,命令会在写出任何结果前失败,不会用替换字符代替无效字节或把不同值合并为一条。 +根目录的 `change-scope` 命令要求提供 `--base `,接受可选的 `--head `(默认为 `HEAD`),并写出一份带版本号的 JSON 报告。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会呈现报告。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分;仓库根目录和每条路径都以严格 UTF-8 解码。遇到无效值时,报告会中止,不会用替换字符代替无效字节或把不同值合并为一条。 已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。每次 Git 探测都会禁用配置的文件系统监视器和可选加锁;diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。 该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。 -聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;配置的文件系统监视器不会执行;互异的非 UTF-8 POSIX 路径、分支名或上游名会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。 +聚焦的临时仓库测试覆盖显式引用与堆叠引用、所有未提交改动层、合法路径空白、严格路径解码、无副作用的探测、无效引用、确定性 schema,以及报告前后不变的引用、索引、配置与状态。 ## 考虑过的替代方案 @@ -30,9 +30,11 @@ Status: implemented **根据变更路径生成必需的测试。** 变更路径无法揭示经由配置、动态加载、子进程、worker、构建产物或提供方触达的行为。pre-push 工作流仍须通过判断来选择证据。 +**报告当前分支与上游,并维护并行的人类可读渲染器。** 调用方在调用前已经验证分支和基准状态,没有消费方使用这些字段,而格式化文字只会重复 JSON schema,并不能提高路径完整性。 + ## 结果 -显式输入仍可能指定错误的基准,但这种错误是可见的:两种输出格式都会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。 +显式输入仍可能指定错误的基准,但这种错误是可见的:报告中会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。 字符串 schema 有意不表示非 UTF-8 路径字节。含有这类路径的仓库必须先重命名这些路径才能生成报告,以此保持范围精确,而非返回有损结果。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index d9dd441374..f933beb86b 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -5,7 +5,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — # Reviewing a DeepSeek-Harness PR -**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm run change-scope --base --head ` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. +**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm --silent run change-scope --base --head ` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. ## Sources of truth diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index 3f817fa464..202b4bcfd8 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -26,7 +26,7 @@ Run the placement test in the standard's taxonomy table, then check the constrai ## Auditing the corpus -The audit is a hunt for the standard's slop checklist, cheapest probes first. Verify and fetch the PR's live base, then run `pnpm run change-scope --base ` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and repeat the audit for prose introduced by the new base rather than relying on the earlier result. +The audit is a hunt for the standard's slop checklist, cheapest probes first. Verify and fetch the PR's live base, then run `pnpm --silent run change-scope --base ` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and repeat the audit for prose introduced by the new base rather than relying on the earlier result. 1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. 2. Hunt narrated history: `rg -n "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts' --glob '!vendor/**'` and keep only contrasts against a live alternative. Keep the vendor exclusion last so include globs cannot override it. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index b00b28f6c9..fe5de961a9 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -19,10 +19,10 @@ git rev-parse --show-toplevel 2. Verify the live PR base or stack parent, fetch that ref, and inspect the complete scope against it. ```sh -pnpm run change-scope --base +pnpm --silent run change-scope --base ``` -The command never guesses or fetches a base. Supply the ref verified from current remote or stack state; use `--head ` when inspecting a commit other than `HEAD`, and `--json` when another tool consumes the report. Its committed paths are relative to the resolved merge base, while staged, unstaged, and untracked paths describe the current worktree. After merging a changed base, rerun the report, reassess which behavior the combined scope can affect, and rerun only checks invalidated by the merge. +The command never guesses or fetches a base. Supply the ref verified from current remote or stack state; use `--head ` when inspecting a commit other than `HEAD`. Its versioned JSON records committed paths relative to the resolved merge base, while staged, unstaged, and untracked paths describe the current worktree. After merging a changed base, rerun the report, reassess which behavior the combined scope can affect, and rerun only checks invalidated by the merge. ## Select relevant evidence diff --git a/scripts/change-scope.spec.ts b/scripts/change-scope.spec.ts index 6f1493d401..a9ba98407b 100644 --- a/scripts/change-scope.spec.ts +++ b/scripts/change-scope.spec.ts @@ -4,11 +4,11 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { writeChangeScope } from './change-scope.ts' +import { renderChangeScope } from './change-scope.ts' interface Report { formatVersion: number - repository: { root: string; branch: string | null; upstream: string | null } + repositoryRoot: string input: { base: string; head: string } resolved: { baseSha: string; headSha: string; mergeBaseSha: string } paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] } @@ -17,7 +17,6 @@ interface Report { interface Fixture { container: string root: string - origin: string } const fixtureRoots: string[] = [] @@ -66,7 +65,7 @@ function fixture(worktreeName = 'worktree'): Fixture { git(root, ['commit', '-m', 'initial']) git(root, ['remote', 'add', 'origin', origin]) git(root, ['push', '--set-upstream', 'origin', 'master']) - return { container, root, origin } + return { container, root } } function commit(root: string, path: string, content: string): string { @@ -77,42 +76,15 @@ function commit(root: string, path: string, content: string): string { } function invoke(root: string, args: string[]): string { - const output: string[] = [] - writeChangeScope(args, root, chunk => output.push(chunk)) - expect(output).toHaveLength(1) - return output[0] as string + return renderChangeScope(args, root) } function jsonReport(root: string, base: string, head?: string): Report { - const args = ['--base', base, '--json'] + const args = ['--base', base] if (head !== undefined) args.push('--head', head) return JSON.parse(invoke(root, args)) as Report } -function formatHumanFromJson(report: Report): string { - const value = (input: string | null): string => JSON.stringify(input) ?? 'null' - const paths = (label: string, entries: string[]): string[] => [ - `${label} (${entries.length}):`, - ...(entries.length === 0 ? [' (none)'] : entries.map(entry => ` - ${value(entry)}`)), - ] - return [ - `Format version: ${report.formatVersion}`, - `Repository root: ${value(report.repository.root)}`, - `Branch: ${value(report.repository.branch)}`, - `Upstream: ${value(report.repository.upstream)}`, - `Base ref: ${value(report.input.base)}`, - `Head ref: ${value(report.input.head)}`, - `Base commit: ${report.resolved.baseSha}`, - `Head commit: ${report.resolved.headSha}`, - `Merge base: ${report.resolved.mergeBaseSha}`, - ...paths('Committed paths', report.paths.committed), - ...paths('Staged paths', report.paths.staged), - ...paths('Unstaged paths', report.paths.unstaged), - ...paths('Untracked paths', report.paths.untracked), - '', - ].join('\n') -} - function repositoryState(root: string): Record { const status = git(root, ['status', '--porcelain=v2', '--branch', '-z']) return { @@ -132,7 +104,7 @@ describe('change-scope', () => { const headSha = commit(root, 'feature.txt', 'feature\n') const fresh = jsonReport(root, 'origin/master') - expect(fresh.repository).toEqual({ root: realpathSync(root), branch: 'feature', upstream: 'origin/master' }) + expect(fresh.repositoryRoot).toBe(realpathSync(root)) expect(fresh.resolved).toEqual({ baseSha: git(root, ['rev-parse', 'origin/master']), headSha, @@ -143,7 +115,6 @@ describe('change-scope', () => { git(root, ['push', '--set-upstream', 'origin', 'feature']) const pushed = jsonReport(root, 'origin/master') - expect(pushed.repository.upstream).toBe('origin/feature') expect(pushed.paths.committed).toEqual(['feature.txt']) }) @@ -151,24 +122,10 @@ describe('change-scope', () => { const { root } = fixture('worktree ') const report = jsonReport(root, 'HEAD') - expect(report.repository.root).toBe(realpathSync(root)) + expect(report.repositoryRoot).toBe(realpathSync(root)) expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] }) }) - it('preserves legal Unicode edge whitespace in branch and upstream names', () => { - const { root } = fixture() - const branch = '\u00a0topic\u3000' - const upstreamBranch = '\u3000upstream\u00a0' - git(root, ['switch', '-c', branch]) - git(root, ['push', 'origin', `HEAD:refs/heads/${upstreamBranch}`]) - git(root, ['branch', '--set-upstream-to', `origin/${upstreamBranch}`]) - - const report = jsonReport(root, 'origin/master') - - expect(report.repository.branch).toBe(branch) - expect(report.repository.upstream).toBe(`origin/${upstreamBranch}`) - }) - it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => { const { root } = fixture() git(root, ['switch', '-c', 'foundation']) @@ -222,55 +179,7 @@ describe('change-scope', () => { expect(existsSync(sideEffect)).toBe(false) }) - it.skipIf(process.platform === 'win32')('rejects non-UTF-8 branch and upstream names without partial output', () => { - const invalidBranch = fixture() - const branchHead = git(invalidBranch.root, ['rev-parse', 'HEAD']) - const invalidBranchName = Buffer.from([0x80]) - writeFileSync(join(invalidBranch.root, '.git/packed-refs'), Buffer.concat([ - Buffer.from(`${branchHead} refs/heads/`), - invalidBranchName, - Buffer.from('\n'), - ])) - writeFileSync( - join(invalidBranch.root, '.git/HEAD'), - Buffer.concat([Buffer.from('ref: refs/heads/'), invalidBranchName, Buffer.from('\n')]), - ) - const branchOutput: string[] = [] - - expect(() => { - writeChangeScope(['--base', branchHead, '--json'], invalidBranch.root, chunk => branchOutput.push(chunk)) - }).toThrow('cannot inspect the current branch: Git stdout is not valid UTF-8') - expect(branchOutput).toEqual([]) - - const invalidUpstream = fixture() - const upstreamHead = git(invalidUpstream.root, ['rev-parse', 'HEAD']) - const invalidUpstreamName = Buffer.from([0x81]) - writeFileSync(join(invalidUpstream.root, '.git/packed-refs'), Buffer.concat([ - Buffer.from(`${upstreamHead} refs/remotes/origin/`), - invalidUpstreamName, - Buffer.from('\n'), - ])) - const configPath = join(invalidUpstream.root, '.git/config') - const config = readFileSync(configPath) - const merge = Buffer.from('\tmerge = refs/heads/master\n') - const mergeIndex = config.indexOf(merge) - expect(mergeIndex).toBeGreaterThanOrEqual(0) - writeFileSync(configPath, Buffer.concat([ - config.subarray(0, mergeIndex), - Buffer.from('\tmerge = refs/heads/'), - invalidUpstreamName, - Buffer.from('\n'), - config.subarray(mergeIndex + merge.length), - ])) - const upstreamOutput: string[] = [] - - expect(() => { - writeChangeScope(['--base', upstreamHead, '--json'], invalidUpstream.root, chunk => upstreamOutput.push(chunk)) - }).toThrow('cannot inspect the configured upstream: Git stdout is not valid UTF-8') - expect(upstreamOutput).toEqual([]) - }) - - it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths without partial output', () => { + it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths', () => { const { root } = fixture() const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content') const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii') @@ -290,15 +199,12 @@ describe('change-scope', () => { secondPath, Buffer.from([0]), ])) - const output: string[] = [] - expect(() => { - writeChangeScope(['--base', 'HEAD', '--json'], root, chunk => output.push(chunk)) + renderChangeScope(['--base', 'HEAD'], root) }).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8') - expect(output).toEqual([]) }) - it('rejects missing, ambiguous, and non-commit refs before writing output', () => { + it('rejects missing, ambiguous, and non-commit refs', () => { const { root } = fixture() git(root, ['branch', 'collision']) git(root, ['tag', 'collision']) @@ -312,32 +218,24 @@ describe('change-scope', () => { { args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u }, { args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u }, ]) { - const output: string[] = [] expect(() => { - writeChangeScope(args, root, (chunk) => { - output.push(chunk) - }) + renderChangeScope(args, root) }).toThrow(message) - expect(output).toEqual([]) } }) - it('renders deterministic human and JSON forms with the same facts', () => { + it('renders deterministic versioned JSON', () => { const { root } = fixture() git(root, ['switch', '-c', 'format']) commit(root, 'zeta.txt', 'zeta\n') commit(root, 'alpha.txt', 'alpha\n') - const json = invoke(root, ['--base', 'origin/master', '--json']) - const repeatedJson = invoke(root, ['--base', 'origin/master', '--json']) - const human = invoke(root, ['--base', 'origin/master']) - const repeatedHuman = invoke(root, ['--base', 'origin/master']) + const json = invoke(root, ['--base', 'origin/master']) + const repeatedJson = invoke(root, ['--base', 'origin/master']) const report = JSON.parse(json) as Report expect(json).toBe(repeatedJson) expect(report.formatVersion).toBe(1) expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt']) - expect(human).toBe(repeatedHuman) - expect(human).toBe(formatHumanFromJson(report)) }) }) diff --git a/scripts/change-scope.ts b/scripts/change-scope.ts index e588f77284..d375a8712e 100644 --- a/scripts/change-scope.ts +++ b/scripts/change-scope.ts @@ -11,11 +11,7 @@ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true }) interface ChangeScopeReport { formatVersion: typeof FORMAT_VERSION - repository: { - root: string - branch: string | null - upstream: string | null - } + repositoryRoot: string input: { base: string head: string @@ -50,7 +46,6 @@ interface GitBytesCommandResult { interface ChangeScopeOptions { base: string head: string - json: boolean } function executeGit(cwd: string, args: string[], context: string): GitCommandResult { @@ -111,12 +106,11 @@ function parseOptions(args: string[]): ChangeScopeOptions { options: { base: { type: 'string' }, head: { type: 'string', default: 'HEAD' }, - json: { type: 'boolean', default: false }, }, strict: true, }) if (values.base === undefined) throw new Error('missing required --base ') - return { base: values.base, head: values.head, json: values.json } + return { base: values.base, head: values.head } } function resolveCommit(root: string, label: 'base' | 'head', ref: string): string { @@ -158,33 +152,6 @@ function resolveMergeBase(root: string, baseSha: string, headSha: string): strin return mergeBases[0] as string } -function currentBranch(root: string): string | null { - const result = executeGit( - root, - ['symbolic-ref', '--quiet', '--short', 'HEAD'], - 'cannot inspect the current branch', - ) - if (result.status === 1) return null - if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`) - return stripGitLineTerminator(result.stdout) -} - -function configuredUpstream(root: string, branch: string | null): string | null { - if (branch === null) return null - const output = stripGitLineTerminator(requireGit( - root, - ['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`], - 'cannot inspect the configured upstream', - )) - return output === '' ? null : output -} - -function comparePaths(left: string, right: string): number { - if (left < right) return -1 - if (left > right) return 1 - return 0 -} - function parsePathSet(output: Buffer, context: string): string[] { const paths: string[] = [] let start = 0 @@ -201,7 +168,7 @@ function parsePathSet(output: Buffer, context: string): string[] { } start = end + 1 } - return [...new Set(paths)].sort(comparePaths) + return [...new Set(paths)].sort() } function diffPaths(root: string, args: string[], context: string): string[] { @@ -232,14 +199,9 @@ function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeRep const baseSha = resolveCommit(root, 'base', options.base) const headSha = resolveCommit(root, 'head', options.head) const mergeBaseSha = resolveMergeBase(root, baseSha, headSha) - const branch = currentBranch(root) return { formatVersion: FORMAT_VERSION, - repository: { - root, - branch, - upstream: configuredUpstream(root, branch), - }, + repositoryRoot: root, input: { base: options.base, head: options.head, @@ -262,56 +224,22 @@ function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeRep } } -function formatValue(value: string | null): string { - return JSON.stringify(value) -} - -function formatPaths(label: string, paths: string[]): string[] { - return [ - `${label} (${paths.length}):`, - ...(paths.length === 0 ? [' (none)'] : paths.map(path => ` - ${formatValue(path)}`)), - ] -} - -function formatHuman(report: ChangeScopeReport): string { - return [ - `Format version: ${report.formatVersion}`, - `Repository root: ${formatValue(report.repository.root)}`, - `Branch: ${formatValue(report.repository.branch)}`, - `Upstream: ${formatValue(report.repository.upstream)}`, - `Base ref: ${formatValue(report.input.base)}`, - `Head ref: ${formatValue(report.input.head)}`, - `Base commit: ${report.resolved.baseSha}`, - `Head commit: ${report.resolved.headSha}`, - `Merge base: ${report.resolved.mergeBaseSha}`, - ...formatPaths('Committed paths', report.paths.committed), - ...formatPaths('Staged paths', report.paths.staged), - ...formatPaths('Unstaged paths', report.paths.unstaged), - ...formatPaths('Untracked paths', report.paths.untracked), - ].join('\n') -} - /** - * Validate arguments, collect one complete report, then invoke the writer once. + * Validate arguments and render one complete versioned report. * @param args - Command-line arguments after the script path. * @param cwd - Directory whose containing Git worktree is inspected. - * @param write - Destination called once only after every Git query succeeds. - * @returns Nothing. + * @returns JSON report with a trailing newline. */ -export function writeChangeScope( - args: string[], - cwd: string, - write: (output: string) => void, -): void { +export function renderChangeScope(args: string[], cwd: string): string { const options = parseOptions(args) const report = collectReport(options, cwd) - write(`${options.json ? JSON.stringify(report, null, 2) : formatHuman(report)}\n`) + return `${JSON.stringify(report, null, 2)}\n` } const entryPath = process.argv[1] if (entryPath !== undefined && resolve(entryPath) === fileURLToPath(import.meta.url)) { try { - writeChangeScope(process.argv.slice(2), process.cwd(), output => process.stdout.write(output)) + process.stdout.write(renderChangeScope(process.argv.slice(2), process.cwd())) } catch (error) { const message = error instanceof Error ? error.message : String(error) process.stderr.write(`change-scope: ${message}\n`) From 3b5be720867b43eb8cb2e2089493ca122e941845 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:41:57 +0800 Subject: [PATCH 14/33] fix(sdk): narrow TypeScript client interface --- ...ipt-sdk-and-sdk-subagent-backend.i18n.yaml | 4 +-- ...typescript-sdk-and-sdk-subagent-backend.md | 8 +++-- ...escript-sdk-and-sdk-subagent-backend.zh.md | 8 +++-- packages/sdk/sdk-client/README.i18n.yaml | 4 +-- packages/sdk/sdk-client/README.md | 6 ++-- packages/sdk/sdk-client/README.zh.md | 6 ++-- packages/sdk/sdk-client/package.json | 1 - packages/sdk/sdk-client/src/client.ts | 31 ++++++++++++++----- packages/sdk/sdk-client/src/index.ts | 21 +++++++++++-- packages/sdk/sdk-client/src/types.ts | 4 +-- .../sdk/sdk-client/tests/sdk-client.spec.ts | 10 +++--- packages/sdk/sdk-protocol/README.i18n.yaml | 6 ++-- packages/sdk/sdk-protocol/README.md | 2 +- packages/sdk/sdk-protocol/README.zh.md | 2 +- packages/sdk/sdk-protocol/package.json | 1 - packages/sdk/sdk-protocol/src/index.ts | 17 ++++++++-- 16 files changed, 89 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index 84c91ba40c..6e57e3f312 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.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-07-27-typescript-sdk-and-sdk-subagent-backend.md -2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: a1481bb9e2c3abfc3111dce8a1c38835436e3e13 -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 64a55f6aa0cb4b9a4a5efc625ece6a0800217f52 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 96ffd772810a7908ff452967aa0be0e540bc2d8c +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: a7062bd356eb77c76799a64c980198200d140e8f diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md index a1481bb9e2..96ffd77281 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -12,8 +12,8 @@ The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-jsonrpc`, the [single-exe Three packages, layered exactly like the existing Python stack, plus one seam registration: -- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/sdk-protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). -- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/sdk-client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Session-tree scoping from `subagent.started` lineage edges is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). +- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/sdk-protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). +- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/sdk-client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). - **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`. - **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. @@ -38,10 +38,12 @@ Four tiers, per [testing policy](../../../../docs/testing.md): **Give the TS SDK bundled-runtime resolution parity with Python.** Python's carrier resolution exists to ship wheels to users without Node. A TypeScript consumer definitionally has Node and (in-repo) the workspace; inventing a distribution story with no consumer violates the require-current-need rule. Deferred until a real npm-distribution consumer appears. +**Export source modules, normalization helpers, and subscription producer operations.** These are implementation seams with no caller need; exposing them would make callers learn how the client validates and distributes wire input. The package roots instead enumerate the supported client and protocol interfaces, and the client re-exports the one protocol error callers must distinguish. + **Reuse `dsh-acp-snapshot`'s `runScenario` for the SDK snapshots.** That harness speaks ACP (`ClientSideConnection`, `InputStep` scripts). The SDK suite's whole point is to drive the *SDK client* as the entry surface; it reuses the normalize/refresh library layer (`normalizeSessionLog`, `refreshFixtureReplacements`, …) and leaves the ACP driver alone. ## Consequences -**Bought**: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors and structured turn reasons; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself. +**Bought**: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself. **Paid**: a third package in the `sdk/` group and a fourth subagent backend to keep current; the SDK backend boots a complete plugin tree per child (heavier per-run than an ACP child; pooling remains future work, same as ACP); the wire still has no cancel method, so both the SDK's `RequestTimeoutError` and the backend's dispose settle locally while the server-side turn runs on until process teardown; fixtures for the snapshot suite were recorded against `deepseek-v4-flash` and re-record on model-behavior drift like every other recorded corpus. diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index 64a55f6aa0..a7062bd356 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -12,8 +12,8 @@ stdio JSON-RPC 服务表面(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执 三个包,分层与既有 Python 栈完全一致,外加一个接缝注册: -- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/sdk-protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 -- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(生成、分帧、通知扇出、有类型的错误表面、经共享处置阶梯关闭至静止)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费者的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess 接缝的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 +- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/sdk-protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 +- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(生成、分帧、通知扇出、有类型的错误表面、经共享处置阶梯关闭至静止)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费者的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess 接缝的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 - **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,以 `subagent-acp` 的同胞结构组织:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样的经 `onError` 汇把结果压平为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未跑回合就尘埃落定的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 - **subagent 接缝增长出 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境擦除、进程树拆除)属于 `dsh-subprocess` 接缝;`subagent-acp` 经 `ctx.subprocess` 生成子进程,本后端则经 SDK 客户端生成(subprocess README 记载的 SDK 托管传输例外)并自行应用接缝的 `scrubbedParentEnv()`。 @@ -38,10 +38,12 @@ stdio JSON-RPC 服务表面(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执 **给 TS SDK 与 Python 对等的捆绑运行时解析。** Python 的载体解析是为了给没有 Node 的用户发 wheel。TypeScript 消费者定义上就有 Node 且(仓库内)有工作区;为不存在的消费者发明发行故事违反"要求当前需求"规则。推迟到真实 npm 发行消费者出现。 +**导出源模块、规范化辅助函数和订阅投递端操作。** 这些都是调用方不需要的实现接缝;暴露它们会让调用方不得不理解客户端如何校验与分发线输入。各包根转而枚举受支持的客户端接口与协议接口,客户端则只重新导出调用方必须区分的那一种协议错误。 + **复用 `dsh-acp-snapshot` 的 `runScenario` 做 SDK 快照。** 那个 harness 说 ACP(`ClientSideConnection`、`InputStep` 脚本)。SDK 套件的全部意义就是以 *SDK 客户端*为入口表面;它复用 normalize/refresh 库层(`normalizeSessionLog`、`refreshFixtureReplacements`……),不动 ACP 驱动器。 ## Consequences -**买到**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费者获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化回合原因;subagent 接缝获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是接缝 Note 预期的递归组合故事;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。 +**买到**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费者获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化回合原因,包根也只暴露归调用方所有的操作;subagent 接缝获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是接缝 Note 预期的递归组合故事;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。 **付出**:`sdk/` 组多了第三个包、subagent 多了第四个要保持最新的后端;SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作);线上仍无取消方法,SDK 的 `RequestTimeoutError` 与后端的 dispose 都只在本地定格、服务器侧回合继续跑到进程拆除为止;快照夹具录制于 `deepseek-v4-flash`,与其他录制语料一样随模型行为漂移而重录。 diff --git a/packages/sdk/sdk-client/README.i18n.yaml b/packages/sdk/sdk-client/README.i18n.yaml index 357d034ba8..5e66e24937 100644 --- a/packages/sdk/sdk-client/README.i18n.yaml +++ b/packages/sdk/sdk-client/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/sdk/sdk-client/README.md -README.md: e2aaf08212307bfac0c73b5e838679a7a750a92a -README.zh.md: cbefae59d95cc0cb9d89145ad3f2ee3248822714 +README.md: 33a933e10abfa865cf9ce34b87c377d07081cc68 +README.zh.md: 9f4453a00efef2685acec0194f83fcec2edf1409 diff --git a/packages/sdk/sdk-client/README.md b/packages/sdk/sdk-client/README.md index e2aaf08212..33a933e10a 100644 --- a/packages/sdk/sdk-client/README.md +++ b/packages/sdk/sdk-client/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides. +The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides. Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern. @@ -20,11 +20,11 @@ const result = await harness.run('say hi') console.log(result.status, result.finalResponse) ``` -The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), plus every `session.event` envelope and raw notification observed for that session tree, in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation. +The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation. ## HarnessClient -The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail). +The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail). `close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse. diff --git a/packages/sdk/sdk-client/README.zh.md b/packages/sdk/sdk-client/README.zh.md index cbefae59d9..9f4453a00e 100644 --- a/packages/sdk/sdk-client/README.zh.md +++ b/packages/sdk/sdk-client/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API,`HarnessClient` 是低层协议客户端。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 +以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。 与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费者——[`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。 @@ -20,11 +20,11 @@ const result = await harness.run('say hi') console.log(result.status, result.finalResponse) ``` -子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本),以及该会话树内按线序观察到的全部 `session.event` 封套与原始通知。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。 +子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按线序排列。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。 ## HarnessClient -回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型:`JsonRpcResponseError`(线上错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。 +回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型且由本包导出:`JsonRpcResponseError`(线上错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。 `close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该接缝记载的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。 diff --git a/packages/sdk/sdk-client/package.json b/packages/sdk/sdk-client/package.json index e0a3655bd5..27341f2040 100644 --- a/packages/sdk/sdk-client/package.json +++ b/packages/sdk/sdk-client/package.json @@ -15,7 +15,6 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ diff --git a/packages/sdk/sdk-client/src/client.ts b/packages/sdk/sdk-client/src/client.ts index 9cf5f1f3d1..af19f26868 100644 --- a/packages/sdk/sdk-client/src/client.ts +++ b/packages/sdk/sdk-client/src/client.ts @@ -71,11 +71,28 @@ interface SubscriptionState { failure: Error | undefined } -/** - * One client-side notification stream. Delivery order matches the wire; - * {@link close} detaches it from the client, after which {@link next} rejects. - */ -export class NotificationSubscription implements AsyncIterable { +/** One client-side notification stream returned by {@link HarnessClient.subscribe}. */ +export interface NotificationSubscription extends AsyncIterable { + /** + * Await the next matching notification. + * @returns the notification; after the runtime died, drains what was + * already delivered and then rejects; after {@link close}, rejects + * immediately (the queue is dropped). + */ + next(): Promise + + /** + * Drain one already-delivered notification without waiting. + * @returns the next queued notification, or `undefined` when none is queued. + */ + tryNext(): HarnessNotification | undefined + + /** Detach from the client; queued items drop and pending waiters reject. */ + close(): void +} + +/** Internal producer side of a public notification subscription. */ +class NotificationSubscriptionImpl implements NotificationSubscription { constructor( private readonly state: SubscriptionState, private readonly unsubscribe: () => void, @@ -168,7 +185,7 @@ export class HarnessClient { private child: ChildProcess | undefined private transport: JsonRpcLineTransport | undefined private readonly stderrTail: string[] = [] - private readonly subscriptions = new Map() + private readonly subscriptions = new Map() private readonly sessionParents = new Map() private subscriptionSerial = 0 private exitCode: number | null | undefined @@ -324,7 +341,7 @@ export class HarnessClient { subscribe(filter?: NotificationFilter): NotificationSubscription { const id = String(this.subscriptionSerial++) const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined } - const subscription = new NotificationSubscription(state, () => { this.subscriptions.delete(id) }) + const subscription = new NotificationSubscriptionImpl(state, () => { this.subscriptions.delete(id) }) if (this.closeTask !== undefined || this.exitCode !== undefined || this.spawnError !== undefined) { subscription.fail(this.closedError('DeepSeek Harness runtime closed')) return subscription diff --git a/packages/sdk/sdk-client/src/index.ts b/packages/sdk/sdk-client/src/index.ts index 6b8ac9a58e..5fd1297a7f 100644 --- a/packages/sdk/sdk-client/src/index.ts +++ b/packages/sdk/sdk-client/src/index.ts @@ -9,6 +9,21 @@ * @module @deepseek-ai/dsh-sdk-client */ -export * from './api.ts' -export * from './client.ts' -export type * from './types.ts' +export { DeepSeekHarness, HarnessSession } from './api.ts' +export type { RunOptions } from './api.ts' +export { + HarnessClient, + RequestTimeoutError, + SdkProtocolError, + TransportClosedError, +} from './client.ts' +export type { NotificationSubscription } from './client.ts' +export { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol' +export type { + ContentBlock, + DeepSeekHarnessOptions, + HarnessClientOptions, + HarnessNotification, + NotificationFilter, + TurnResult, +} from './types.ts' diff --git a/packages/sdk/sdk-client/src/types.ts b/packages/sdk/sdk-client/src/types.ts index 96a70ee86a..ac4393126b 100644 --- a/packages/sdk/sdk-client/src/types.ts +++ b/packages/sdk/sdk-client/src/types.ts @@ -67,9 +67,9 @@ export interface TurnResult { reason: TurnEndReason | undefined /** Concatenated text of the session's last assistant message (empty when none). */ finalResponse: string - /** Every `session.event` payload for this session tree, in wire order. */ + /** Every `session.event` payload for the root session, in wire order. */ events: SessionEvent[] - /** Every notification observed during the turn, in wire order. */ + /** Every notification for the root session and discovered descendants, in wire order. */ notifications: HarnessNotification[] } diff --git a/packages/sdk/sdk-client/tests/sdk-client.spec.ts b/packages/sdk/sdk-client/tests/sdk-client.spec.ts index 171c0f0655..ef01d28c9c 100644 --- a/packages/sdk/sdk-client/tests/sdk-client.spec.ts +++ b/packages/sdk/sdk-client/tests/sdk-client.spec.ts @@ -12,15 +12,14 @@ import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { DeepSeekHarness, - finalResponse, HarnessClient, - normalizeInput, + JsonRpcResponseError, RequestTimeoutError, SdkProtocolError, TransportClosedError, type HarnessNotification, } from '../src/index.ts' -import { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol' +import { finalResponse, normalizeInput } from '../src/api.ts' const fakeRuntime = fileURLToPath(new URL('./fake-runtime.ts', import.meta.url)) @@ -69,7 +68,7 @@ describe('DeepSeekHarness', () => { await harness.close() }) - it('streams notifications to the observer and scopes them to the session tree', async () => { + it('keeps events root-scoped while streaming notifications for the session tree', async () => { const harness = harnessWith({ FAKE_SUBAGENT: '1' }) const seen: HarnessNotification[] = [] const result = await harness.run('delegate', { @@ -83,7 +82,8 @@ describe('DeepSeekHarness', () => { expect(seen.map(n => n.method)).toContain('subagent.finished') const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child') expect(childEvents.length).toBeGreaterThan(0) - // Child events do not count as the parent's own turn events. + // TurnResult.events is the root session's typed stream; descendants retain + // their session ids in the raw notification stream above. expect(result.events.every(event => event.type !== 'assistant/message' || (event.data as { content: { type: string; text?: string }[] }).content[0]?.text !== 'child says hi')).toBe(true) await harness.close() diff --git a/packages/sdk/sdk-protocol/README.i18n.yaml b/packages/sdk/sdk-protocol/README.i18n.yaml index 07a2a188ed..c7802c1795 100644 --- a/packages/sdk/sdk-protocol/README.i18n.yaml +++ b/packages/sdk/sdk-protocol/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 61ffc0e17700d79da14001b389c7c6dcb50ee28d -README.zh.md: 9de816dc588354d04194d5eb99f444409456046e +# pnpm run verify-translation-pairing --write packages/sdk/sdk-protocol/README.md +README.md: 79e6bc36a656ce0d68c8e01ab2f75e26b4ac8ca5 +README.zh.md: 8322da0f2bf7251f2b958c6a15f1738b9d8c41a4 diff --git a/packages/sdk/sdk-protocol/README.md b/packages/sdk/sdk-protocol/README.md index 61ffc0e177..79e6bc36a6 100644 --- a/packages/sdk/sdk-protocol/README.md +++ b/packages/sdk/sdk-protocol/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The server side is the [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) plugin; clients are [`dsh-sdk-client`](../sdk-client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration. +The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The package root enumerates the protocol consumer interface; source modules are not exported as deep imports. The server side is the [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) plugin; clients are [`dsh-sdk-client`](../sdk-client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration. ## Transport diff --git a/packages/sdk/sdk-protocol/README.zh.md b/packages/sdk/sdk-protocol/README.zh.md index 9de816dc58..8322da0f2b 100644 --- a/packages/sdk/sdk-protocol/README.zh.md +++ b/packages/sdk/sdk-protocol/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -DeepSeek Harness SDK 运行时的共享线协议:一个按换行分帧的 JSON-RPC 2.0 传输类,加上线两端共同使用的具名请求、结果与通知类型。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者镜像这些形状但不导入它们)。纯库——无插件、无 Config、无注册。 +DeepSeek Harness SDK 运行时的共享线协议:一个按换行分帧的 JSON-RPC 2.0 传输类,加上线两端共同使用的具名请求、结果与通知类型。包(package)根枚举协议消费方接口;源模块不以深层导入形式导出。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者镜像这些形状但不导入它们)。纯库——无插件、无 Config、无注册。 ## 传输 diff --git a/packages/sdk/sdk-protocol/package.json b/packages/sdk/sdk-protocol/package.json index 6eba1c3091..bc4591a669 100644 --- a/packages/sdk/sdk-protocol/package.json +++ b/packages/sdk/sdk-protocol/package.json @@ -15,7 +15,6 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ diff --git a/packages/sdk/sdk-protocol/src/index.ts b/packages/sdk/sdk-protocol/src/index.ts index da2d54c170..c11a270f47 100644 --- a/packages/sdk/sdk-protocol/src/index.ts +++ b/packages/sdk/sdk-protocol/src/index.ts @@ -8,5 +8,18 @@ * @module @deepseek-ai/dsh-sdk-protocol */ -export * from './transport.ts' -export * from './types.ts' +export { JsonRpcLineTransport, JsonRpcResponseError } from './transport.ts' +export type { JsonRpcTransportPeer } from './transport.ts' +export type { + HarnessSdkNotificationMap, + HarnessSdkRequestMap, + InitializeParams, + InitializeResult, + SdkRunStatus, + SessionEventNotification, + SessionFinishedNotification, + SessionPromptParams, + SessionPromptResult, + SubagentFinishedNotification, + SubagentStartedNotification, +} from './types.ts' From 7503fa896f35cb009781706ba316e7935c67e0e5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:43:40 +0800 Subject: [PATCH 15/33] fix(snapshot): pass refresh context in SDK snapshots --- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 53776c1662..26254f0cf1 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -257,7 +257,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const existing = expectedContents[index] const file = files[index] if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`) - const stable = stabilizeRefreshLog(log.content, existing, replacements) + const stable = stabilizeRefreshLog(log.content, existing, replacements, actualContext) await writeFile(file, stable) return stable })) From b2b063b6675d1ea9e27490086d25328bb4e64e88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:43:26 +0000 Subject: [PATCH 16/33] chore(deps): bump actions/deploy-pages from 4 to 5 Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5. - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/docs-pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 34a29c1293..59e5264512 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -69,4 +69,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From a43a2032ecbe20fd87a75b9c2cf610223034984f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 28 Jul 2026 11:05:53 +0800 Subject: [PATCH 17/33] fix(tui): search session references by title --- ...6-07-21-cross-session-references.i18n.yaml | 6 +-- .../2026-07-21-cross-session-references.md | 6 +-- .../2026-07-21-cross-session-references.zh.md | 6 +-- docs/cordis-catalog/services.md | 2 +- .../session-reference/README.i18n.yaml | 6 +-- packages/context/session-reference/README.md | 6 +-- .../context/session-reference/README.zh.md | 6 +-- .../context/session-reference/src/index.ts | 53 ++++++++++++------- .../tests/session-reference.spec.ts | 39 +++++++++++++- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../session-title-autocomplete.expected.txt | 24 +++++++++ packages/ui/tui/tests/tui.snapshot.ts | 27 ++++++++++ packages/ui/tui/tests/tui.spec.ts | 51 ++++++++++++++---- 13 files changed, 183 insertions(+), 51 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/session-title-autocomplete.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index b1f73ed15a..27d446ac11 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-cross-session-references.md: fc084b36e7920a72efff0f363278d24eaebc4c69 -2026-07-21-cross-session-references.zh.md: fe4a876b5265fa7ad298adf3b829bcec70e878e8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md +2026-07-21-cross-session-references.md: 93dc2fcc50225b98cbea1ce3e1f9cbac947bf59e +2026-07-21-cross-session-references.zh.md: 896bf8e52a25503ad5f1e99588b9432319be14ff diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index fc084b36e7..93dc2fcc50 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -14,7 +14,7 @@ TUI users need to bring relevant work from another conversation into one new mes `dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)`; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. -The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: v1 discovery filters only id and cwd, and future title/body search can replace the candidate layer without changing reference identity or preparation. +The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: discovery matches id, cwd, or the latest folded title, while message bodies remain outside the candidate layer. Non-empty queries batch title observations across the visible corpus with bounded persisted-log concurrency and cancellation; a dedicated title index can replace that discovery path without changing reference identity or preparation. ## Snapshot and projection @@ -32,7 +32,7 @@ This preserves host driving semantics: TUI decides `send()` versus `steer()` fro ## Host adapters -TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal. +TUI combines session candidates with the existing `@` file provider. Candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -53,7 +53,7 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, and compact TUI replay. One keyless terminal snapshot types a title-only substring against an opaque session id and pins the rendered candidate. Another keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index fe4a876b52..896bf8e52a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -14,7 +14,7 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 `dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 -该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是功能依赖:v1 的候选发现只按 id 和 cwd 过滤;未来的标题或正文搜索可以替换候选层,而无需改变引用标识或准备过程。 +该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是功能依赖:候选发现会匹配 id、cwd 或最新折叠后的标题,而消息主体不进入候选层。非空查询会对可见语料中的标题观察结果执行批处理,以有界并发读取持久化日志,并支持取消;专用标题索引可以替换这条发现路径,而无需改变引用标识或准备过程。 ## 快照与投影 @@ -32,7 +32,7 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 宿主适配器 -TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 +TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -53,7 +53,7 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。一个无密钥终端快照会在会话 id 不透明的情况下输入一个只与标题匹配的子串,并固定渲染出的候选项。另一个无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6785e5f983..97ff1e0d7d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1197,7 +1197,7 @@ Exact-read consumer that prepares immutable cross-session message context. /** * List reference candidates, ranked by working-directory affinity. * @param agent - target agent; self is excluded and its cwd drives ranking. - * @param query - optional case-insensitive session-id/cwd substring. + * @param query - optional case-insensitive session-id/cwd/title substring. * @param limit - optional positive result cap. * @param signal - optional cancellation boundary for host autocomplete teardown. * @returns candidates labeled by latest title or, when absent, session id. diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 83155d4c0e..960cb2a7bc 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: c995256511742c193e064cf808fc89194b444974 -README.zh.md: e2e67cfee745c84d6c85e8792e53c50bf2648293 +# pnpm run verify-translation-pairing --write packages/context/session-reference/README.md +README.md: 97b8b44dd0822010f64397ebd4632edbeccb6863 +README.zh.md: 921fa103e9fab0de5cdbb6ed7ef6ae863e41e3dc diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index c995256511..97b8b44dd0 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -6,7 +6,7 @@ English | [中文](README.zh.md) ## Public API -- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched. +- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched. - `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. @@ -21,7 +21,7 @@ The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `pl | Key | Default | Contract | |---|---:|---| | `maxReferences` | `3` | Maximum distinct source sessions in one prepared message; must be at most `3`. | -| `candidateLimit` | `50` | Default metadata candidate count returned to a host. | +| `candidateLimit` | `50` | Default candidate count returned to a host. | | `maxReferenceBytes` | `65536` | Maximum serialized JSON bytes for one reference object. | Retention applies `maxReferenceBytes` independently to each source, keeps compact checkpoints and the newest message before dropping older non-checkpoint units, and uses `dsh-retention` head/tail truncation with an exact UTF-8 omission notice. If one source's fixed serialized fields cannot fit, preparation fails with `SESSION_REFERENCE_BUDGET_EXCEEDED` instead of returning a partial context. @@ -44,7 +44,7 @@ The combined snapshot and request are append-only at the target message boundary ## Known Limitations and Deferred Work -- **No title or full-text discovery** — candidates filter by session id and cwd only, although selected rows display the latest title. SQLite FTS may replace discovery later without changing URI, snapshot, or persistence contracts. +- **No body discovery** — candidate queries inspect folded titles but do not search message bodies. A non-empty query may inspect every visible persisted session log through the session-query service's bounded, cancellable batch; a dedicated title index may replace that discovery path without changing URI, snapshot, or persistence contracts. - **Trusted caller boundary** — the service assumes its host is authorized to read every session exposed by `ctx.sessionQuery`; it is not a model-facing search tool. - **Text projection only** — non-text user and assistant blocks are not propagated across sessions. - **No live link** — references are snapshots, not forks, resumes, subscriptions, or source-session mutations. diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index e2e67cfee7..921fa103e9 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -6,7 +6,7 @@ ## 公开 API -- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id 或 cwd 进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用最新的日志支持标题作为 mention label,并回退到会话 id;不搜索标题与消息主体。 +- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或日志中最新的标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。 - `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `HookContext`。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()` 或 `steer()` 之前被拒绝。 - `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。显式 Markdown mention 会拒绝每个格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。 @@ -21,7 +21,7 @@ | Key | 默认值 | 契约 | |---|---:|---| | `maxReferences` | `3` | 一条已准备消息中不同源会话的最大数量;必须不大于 `3`。 | -| `candidateLimit` | `50` | 返回给宿主的默认元数据候选数量。 | +| `candidateLimit` | `50` | 返回给宿主的默认候选数量。 | | `maxReferenceBytes` | `65536` | 一个引用对象的最大序列化 JSON 字节数。 | 保留会对每个源独立应用 `maxReferenceBytes`,保留 compact 检查点与最新消息,再丢弃较旧的非检查点单元,并使用 `dsh-retention` 头部/尾部截断和精确 UTF-8 省略通知。如果某个源的固定序列化字段无法容纳,准备会以 `SESSION_REFERENCE_BUDGET_EXCEEDED` 失败,而不返回部分上下文。 @@ -44,7 +44,7 @@ ## 已知限制与暂缓事项 -- **没有标题或全文发现**:候选会话只按会话 id 与 cwd 筛选,但已选行会显示最新标题。SQLite FTS 未来可以替换发现机制,而不改变 URI、快照或持久化契约。 +- **不支持正文发现**:候选查询会检查折叠后的标题,但不搜索消息主体。非空查询可能通过 session-query 服务有界、可取消的批处理检查每个可见的持久化会话日志;专用标题索引未来可以替换这条发现路径,而不改变 URI、快照或持久化契约。 - **受信任调用方边界**:该服务假设宿主有权读取 `ctx.sessionQuery` 公开的每个会话;它不是面向模型的搜索工具。 - **只投影文本**:不会在会话间传播非文本 user 与 assistant 块。 - **没有实时链接**:引用是快照,不是 fork、恢复、订阅或源会话变更。 diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 93e5173005..0c74505823 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -10,7 +10,7 @@ import z from 'schemastery' import type { Agent, HookContext } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' +import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query' import { DEFAULT_CANDIDATE_LIMIT, DEFAULT_MAX_REFERENCE_BYTES, @@ -102,7 +102,7 @@ export class SessionReferenceService extends Service { /** * List reference candidates, ranked by working-directory affinity. * @param agent - target agent; self is excluded and its cwd drives ranking. - * @param query - optional case-insensitive session-id/cwd substring. + * @param query - optional case-insensitive session-id/cwd/title substring. * @param limit - optional positive result cap. * @param signal - optional cancellation boundary for host autocomplete teardown. * @returns candidates labeled by latest title or, when absent, session id. @@ -119,27 +119,42 @@ export class SessionReferenceService extends Service { const needle = query.toLocaleLowerCase() const targetCwd = agent.session.header.cwd assertNotCancelled(signal) - const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(), signal)) + const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal)) .filter(record => record.header.id !== agent.id) - .filter((record) => { - if (needle === '') return true - return record.header.id.toLocaleLowerCase().includes(needle) - || record.header.cwd?.toLocaleLowerCase().includes(needle) === true - }) .map((record, index) => ({ record, index })) - .sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) - || a.index - b.index) - .slice(0, limit) - const titles = await settleWithCancellation( - Promise.all(records.map(({ record }) => this.ctx.sessionQuery.readTitle(record.header.id))), + const inspected = needle === '' + ? records + .sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) + || a.index - b.index) + .slice(0, limit) + : records + const observations = await settleWithCancellation( + this.ctx.sessionQuery.readTitleSnapshots(inspected.map(({ record }) => record.header.id), signal), signal, ) - return records.map(({ record }, index) => ({ - sessionId: record.header.id, - label: titles[index]?.title ?? record.header.id, - ...record.header.cwd === undefined ? {} : { cwd: record.header.cwd }, - createdAt: record.header.createdAt, - })) + return inspected.map(({ record, index }, observationIndex) => { + const observation = observations[observationIndex] as SessionTitleObservationResult + return { + record, + index, + label: observation.status === 'fulfilled' + ? observation.value.title?.title ?? record.header.id + : record.header.id, + } + }).filter(({ record, label }) => { + if (needle === '') return true + return record.header.id.toLocaleLowerCase().includes(needle) + || record.header.cwd?.toLocaleLowerCase().includes(needle) === true + || label.toLocaleLowerCase().includes(needle) + }).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) + || a.index - b.index) + .slice(0, limit) + .map(({ record, label }) => ({ + sessionId: record.header.id, + label, + ...record.header.cwd === undefined ? {} : { cwd: record.header.cwd }, + createdAt: record.header.createdAt, + })) } /** diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 4bcca1a10f..a929ed862b 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -188,7 +188,7 @@ describe('session reference URI and inline mentions', () => { }) describe('session reference discovery and preparation', () => { - it('ranks metadata candidates by cwd without depending on full-text search', async () => { + it('matches candidate metadata and titles before ranking by cwd', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same', createdAt: 10 } }) ctx.sessions.create(SessionId('other'), { meta: { cwd: '/else', createdAt: 40 } }) @@ -210,6 +210,9 @@ describe('session reference discovery and preparation', () => { await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([ { sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 }, ]) + await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([ + { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 }, + ]) await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), '', 0)) .rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE')) @@ -229,6 +232,40 @@ describe('session reference discovery and preparation', () => { listSessions.mockRestore() }) + it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target')) + const source = ctx.sessions.create(SessionId('source')) + const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') + readTitles.mockResolvedValueOnce([{ + sessionId: source.id, + status: 'rejected', + reason: new Error('broken title log'), + }]) + + await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([ + { sessionId: source.id, label: source.id, createdAt: source.header.createdAt }, + ]) + + let releaseTitles: (() => void) | undefined + let titleSignal: AbortSignal | undefined + readTitles.mockImplementationOnce(async (_ids, signal) => { + titleSignal = signal + await new Promise((resolve) => { releaseTitles = resolve }) + return [] + }) + const controller = new AbortController() + const pending = ctx.sessionReferences.listCandidates(fakeAgent(target), 'source', undefined, controller.signal) + await vi.waitFor(() => { expect(releaseTitles).toBeTypeOf('function') }) + expect(titleSignal).toBe(controller.signal) + const cancelledTitles = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED')) + controller.abort('autocomplete superseded') + await cancelledTitles + releaseTitles?.() + await Promise.resolve() + readTitles.mockRestore() + }) + it('projects only the current user/assistant surface and records snapshot metadata', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/target' } }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ca1fec1dc5..560a329c8f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -598,7 +598,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async listCandidates( agent: Agent, query = \'\', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise', - jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */', + jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd/title substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */', }, { signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise', diff --git a/packages/ui/tui/tests/snapshots/session-title-autocomplete.expected.txt b/packages/ui/tui/tests/snapshots/session-title-autocomplete.expected.txt new file mode 100644 index 0000000000..5ac4887b22 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/session-title-autocomplete.expected.txt @@ -0,0 +1,24 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=8 viewportRow=4 bufferRow=4 +viewport +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +4| " @design " + style 8-8 inverse +5| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +6| " → Session · Searchable design re opaque-source-id · /workspace/project · 1970-01-01T00:00:0 " + style 1-32 fg=bright-blue +7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" + style 0-43 dim + style 69-95 dim +8-35| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index a507db017a..bcc67ff044 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -8,6 +8,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent' import { CallId, ReasoningEffortId, type ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session' +import SessionReferenceService from '@deepseek-ai/dsh-session-reference' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -21,6 +22,7 @@ import { type TuiHarnessOptions, } from './harness.ts' import { HeadlessTerminal, type TerminalSnapshotOptions } from './headless-terminal.ts' +import { TestSessionQueryService } from './session-query.ts' const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh' @@ -33,6 +35,7 @@ const CHECKPOINTS = [ 'retry-exhausted', 'banner-gradient', 'file-autocomplete', + 'session-title-autocomplete', 'code-mode-pending', 'dynamic-workflow-pending', 'cordis-tools-pending', @@ -370,6 +373,30 @@ describe('TUI terminal-state snapshots', () => { } }) + it('pins session autocomplete discovered through a log-backed title', async () => { + const harness = await setupSnapshot({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + await ctx.plugin(TestSessionQueryService) + await ctx.plugin(SessionReferenceService) + const source = ctx.sessions.create(SessionId('opaque-source-id'), { + meta: { cwd: '/workspace/project', createdAt: 1 }, + }) + source.append('session/title', { + title: 'Searchable design review', + messageSeqs: [], + source: { kind: 'fallback' }, + }) + }, + }) + harness.terminal.send('@design') + await vi.waitFor(async () => { + expect(await harness.terminal.snapshot()).toContain('Session · Searchable design re') + }) + await checkpoint('session-title-autocomplete', harness.terminal) + await disposeSnapshot(harness) + }) + it('pins Code Mode run_code with its production presenter', async () => { const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) const call = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index b59054659f..589eded061 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1834,21 +1834,50 @@ describe('pi-tui chat lifecycle and transcript', () => { }) it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => { - let sourceId = SessionId('uninitialized') + const sourceId = SessionId('source-session') + const sourceHeader: SessionHeader = { + version: 0, + id: sourceId, + cwd: '/workspace', + createdAt: 1, + } + const noCwdHeader: SessionHeader = { + version: 0, + id: SessionId('no-cwd'), + createdAt: 2, + } + const sourceEvents: SessionEvent[] = [ + { + type: 'user/message', + seq: 0, + time: 1, + data: { content: [{ type: 'text', text: 'source background' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, + { + type: 'session/title', + seq: 1, + time: 2, + data: { + title: 'Source chat', + messageSeqs: [0], + source: { kind: 'fallback' }, + }, + }, + ] const result = await setup({ + sessionPersistence: { + list: async () => [noCwdHeader, sourceHeader], + load: async (id) => { + if (id === sourceId) return { meta: sourceHeader, events: sourceEvents } + if (id === noCwdHeader.id) return { meta: noCwdHeader, events: [] } + throw new Error(`unexpected persisted session ${id}`) + }, + }, async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) - const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } }) - sourceId = source.id - appendUser(source, 'source background') - source.append('session/title', { - title: 'Source chat', - messageSeqs: [0], - source: { kind: 'fallback' }, - }) - ctx.sessions.create(SessionId('no-cwd'), { meta: { createdAt: 2 } }) }, }) @@ -1857,7 +1886,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('(no cwd)') result.terminal.send('\x03') - result.terminal.send('@source-session') + result.terminal.send('@chat') await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · Source chat') }) expect(result.terminal.output).toContain('source-session') result.terminal.send('\t') From 99d631d41abeaa1480335f367547da8b9f6ad445 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 28 Jul 2026 11:11:42 +0800 Subject: [PATCH 18/33] fix: ci --- .../ui-conversation/src/client/chat/AssistantMarkdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 52fdc14217..6daf78719e 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -43,9 +43,9 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea // 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 // between tool groups — skip the shell unless something visible remains. - const hasVisible = streaming === true + const hasVisible = streaming || interrupted === true - || blocks.some((block) => block.kind !== 'tool-call') + || blocks.some(block => block.kind !== 'tool-call') if (!hasVisible) return null return (
From c4df0628db2445ce590d9d4544bd0f40414df5b5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:53:29 +0800 Subject: [PATCH 19/33] docs(notes): record reactive provide projection and lexicon subscription decisions --- ...5-web-client-session-scope-and-provide-channel.i18n.yaml | 4 ++-- ...26-07-25-web-client-session-scope-and-provide-channel.md | 2 +- ...07-25-web-client-session-scope-and-provide-channel.zh.md | 2 +- .../2026-07-25-web-command-surfaces-and-assembly.i18n.yaml | 6 +++--- .../2026-07-25-web-command-surfaces-and-assembly.md | 4 ++-- .../2026-07-25-web-command-surfaces-and-assembly.zh.md | 4 ++-- ...026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml | 6 +++--- .../2026-07-25-web-input-machine-and-slash-pipeline.md | 4 ++-- .../2026-07-25-web-input-machine-and-slash-pipeline.zh.md | 4 ++-- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index f3a525fe70..4783b79a6a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 09afe6d9e879ae7529d309c3b5e656be849fa543 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5 +2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 09afe6d9e8..4496e3786e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -90,7 +90,7 @@ The sole provisioning path by which session slot components fetch their own sess Slot scope is the closed set `root | session-maybe | session`: - `root` receives only the global standard kit, with no session identity or provisioning. -- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates, while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session. +- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. `conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 4d45d74c2e..768dada95a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -90,7 +90,7 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件 slot scope 是闭集 `root | session-maybe | session`: - `root` 只拿全局标准件,不接收 session 身份或供数。 -- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 驱动这条更新,`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。 +- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。 - `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 `conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view,composer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index d636aab9ff..29f56666b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b -2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +2026-07-25-web-command-surfaces-and-assembly.md: 4c4a400abab940baebc1699fc15b709419865f0c +2026-07-25-web-command-surfaces-and-assembly.zh.md: c0acd1ecc5998a0ec488a1f13ed99ae4a93a240b diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md index 5188e8c17b..4c4a400aba 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md @@ -28,8 +28,8 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx ### Reference sources (seeing only projections plus their own apply closures, on the root ctx) -- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). -- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream). +- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). +- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream). ### Fixture command routing and assembly diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md index 0134cc10cf..c0acd1ecc5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -28,8 +28,8 @@ Status: implemented ### 引用源(只见投影 + 自家 apply 闭包的 root ctx) -- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 -- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。 +- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 +- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。 ### fixture 命令路由与装配 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 0249baff80..121879629c 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-web-input-machine-and-slash-pipeline.md: acbd132a5fdb97a4098064aae689dfca604ad4b7 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 158650a41b47f98037a1b3e610d9294694c55a8c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index acbd132a5f..8cf3be7b3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -81,10 +81,10 @@ A trigger/menu/pick pipeline with zero knowledge of "commands": skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived: - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes. -- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the controller aggregates it into the `lexicon()` public surface. +- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast. - `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan. - Sending is the literal text (no more `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` tag + plain-text tokens). -- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Known limitation kept as-is: with the lexicon not warm at paste / cold start there is no decoration — it lights up only after typing `/` opens the menu once. +- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. ### Per-session provide contributions and the private keyboard surface diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 158650a41b..b148889355 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -81,10 +81,10 @@ occurrence 表与 chip 三投影: skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draft,chip 视觉纯派生: - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同契约:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 -- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);controller 聚合为 `lexicon()` 公面。 +- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` snapshot store(每次 source 通知重拉);scope 出生后才注册的 source 由 service 广播给活 controller,补 warm 并并入名录。 - `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。 - 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 token)。 -- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。已知局限维持现状:粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮。 +- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 ### per-session 供数贡献与键盘私面 From a0b618abb96ae619858bbb2f852fc924bc4fda44 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:53:50 +0800 Subject: [PATCH 20/33] fix(client): publish current session provide bundle as one reactive projection A provider roster change under a stable current id rematerialized every scope's bundle but nothing notified React: SessionProvider resolved the bundle from a current-id subscription only, so mounted entries kept the obsolete hook/prop schema until an unrelated re-render. The sessions service now owns an atomic currentProvide observable fed by both current writes and roster changes; the renderer host exposes it as sessions.provide, replacing the current/provideInfo/maybeProvideInfo trio, and both providers subscribe to it. --- .../runtime/src/client/sessions/service.ts | 49 ++++++++++++++--- packages/client/runtime/src/client/slots.ts | 11 +--- .../runtime/tests/sessions-service.spec.ts | 54 +++++++++++++++++++ .../runtime/tests/slots-service.spec.ts | 17 ++---- .../tests/apply-inject.spec.tsx | 3 +- .../ui-conversation/tests/chat-apply.spec.tsx | 3 +- .../tests/chat-code-subcalls.spec.tsx | 10 ++-- .../tests/chat-toolview-slot.spec.tsx | 46 ++++++++-------- .../tests/selection-survival.spec.ts | 8 ++- packages/client/ui-slots/src/renderer.ts | 16 +++--- .../client/web-react/src/session-provider.tsx | 20 +++---- .../tests/scoped-slots-real-core.spec.tsx | 5 +- .../web-react/tests/scoped-slots.spec.tsx | 20 ++++--- .../web-react/tests/session-provider.spec.tsx | 50 ++++++++++++++--- .../tests/stale-authorization.spec.tsx | 5 +- 15 files changed, 222 insertions(+), 95 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec8ddc2354..03eca7724f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -151,6 +151,13 @@ export class SessionsService { readonly list: SnapshotStore /** The object-layer instance cluster and frame dispatch entry. */ private readonly manager: SessionManager + /** + * Atomic current-session provide projection: selection changes and + * provider-roster changes publish through this one source (the renderer + * host's `sessions.provide` feed), so a roster change under a stable + * current id republishes the bundle instead of stranding mounted entries. + */ + readonly currentProvide: HostObservable /** * Persisted selection cell (the durable half of `list.current`). Private on @@ -167,6 +174,10 @@ export class SessionsService { private readonly providers: SessionProvideDescriptor[] = [] /** Static no-session projection, rebuilt only when the provider roster changes. */ private maybeInfo: SessionMaybeProvideInfo + /** Latest published {@link SessionsService.currentProvide} bundle (identity comparison dedupes republish). */ + private currentProvideSnapshot: SessionMaybeProvideInfo + /** currentProvide subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */ + private readonly currentProvideListeners = new Set<() => void>() /** * The staged session id — follows `list.current` exactly, holding its last * defined value across masked gaps (a transiently absent selection blanks @@ -198,7 +209,11 @@ export class SessionsService { // dedicated code path. Safe to run synchronously inside the store notify: // the follower writes no list state — session.open()'s synchronous prefix // touches only session-side state and its own microtask-batched notifier. - this.list.subscribe(() => { this.followCurrent() }) + // The current-provide projection follows the same current writes. + this.list.subscribe(() => { + this.followCurrent() + this.projectCurrentProvide() + }) // The runtime's own contribution comes first: useSession rides the same // provide channel every plugin uses (no renderer special case). this.providers.push({ @@ -206,6 +221,14 @@ export class SessionsService { resolve: binding => ({ hooks: { session: binding.session } }), }) this.maybeInfo = this.materializeMaybeProvideInfo() + this.currentProvideSnapshot = this.maybeInfo + this.currentProvide = { + getSnapshot: () => this.currentProvideSnapshot, + subscribe: (fn) => { + this.currentProvideListeners.add(fn) + return () => { this.currentProvideListeners.delete(fn) } + }, + } rootCtx.reflect.provide('sessions', this, undefined) } @@ -238,6 +261,20 @@ export class SessionsService { for (const record of this.scopes.values()) { record.provideInfo = this.materializeProvideInfo(record.binding) } + this.projectCurrentProvide() + } + + /** + * Publish the current selection's provide bundle when it changed. Bundles + * are identity-stable per (scope, roster) materialization, so an identity + * compare is exact; synchronous notify — both call sites (list.subscribe, + * provide()) already sit behind their own batching or registration edges. + */ + private projectCurrentProvide(): void { + const next = this.maybeProvideInfo(this.list.getSnapshot().current) + if (next === this.currentProvideSnapshot) return + this.currentProvideSnapshot = next + for (const fn of [...this.currentProvideListeners]) fn() } /** Build the static no-session kit and reject duplicate declared names. */ @@ -404,11 +441,11 @@ export class SessionsService { } /** - * Resolve the render-layer standard-props bundle (SessionProvider's feed - * through the renderer host; ctx never enters the render layer). Pure - * resolution — render-safe: SessionProvider calls this during render, so no - * staging, no window side effects (StrictMode double-invokes and concurrent - * discarded passes must stay free). + * Resolve one session's render-layer standard-props bundle (ctx never + * enters the render layer; the renderer subscribes to + * {@link SessionsService.currentProvide}). Pure resolution — render-safe: + * no staging, no window side effects (StrictMode double-invokes and + * concurrent discarded passes must stay free). * @param id - session id. * @returns the provide info, or undefined for a session neither listed nor already scoped. */ diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 74af31502a..ed10826b9d 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -246,13 +246,6 @@ export class SlotsService extends Service { if (workspaces === undefined) { throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") } - // Identity-stable view: current rides the list snapshot (arbitrated), but - // the provider consumes it as its own observable; one cached object keeps - // the renderer's per-source hook cache stable. - const current = { - getSnapshot: () => sessions.list.getSnapshot().current as string | undefined, - subscribe: (fn: () => void) => sessions.list.subscribe(fn), - } this._host = { subscribe: (key, fn) => this._core.subscribe(key, fn), getVersion: key => this._core.getVersion(key), @@ -263,9 +256,7 @@ export class SlotsService extends Service { entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), sessions: { list: sessions.list, - current, - provideInfo: id => sessions.provideInfo(id), - maybeProvideInfo: id => sessions.maybeProvideInfo(id), + provide: sessions.currentProvide, }, workspaces: { list: workspaces.list }, } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 44ab4ffb4f..2f90c1c301 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -195,6 +195,60 @@ describe('cell (render-layer session kit)', () => { expect(b.svc.provideInfo('ghost')).toBeUndefined() }) + it('currentProvide follows selection: absent projection ↔ definite bundle, notified on each move', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }, { id: 's2' }]) + const absent = b.svc.currentProvide.getSnapshot() + expect(absent.sessionId).toBeUndefined() + expect(Object.hasOwn(absent.hooks, 'session')).toBe(true) + const notified = vi.fn() + b.svc.currentProvide.subscribe(notified) + b.svc.open(sid('s1')) + expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s1')) + expect(notified).toHaveBeenCalledTimes(1) + b.svc.open(sid('s2')) + expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s2')) + expect(notified).toHaveBeenCalledTimes(2) + b.svc.clear() + await Promise.resolve() // clearSelection projects through the manager notifier + expect(b.svc.currentProvide.getSnapshot().sessionId).toBeUndefined() + }) + + it('a provider roster change under a stable current id republishes the bundle', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + const before = b.svc.currentProvide.getSnapshot() + const notified = vi.fn() + b.svc.currentProvide.subscribe(notified) + const source = { getSnapshot: () => 'live', subscribe: () => () => {} } + const dispose = b.svc.provide({ + hooks: ['extra'], + props: ['marker'], + resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }), + }) + const added = b.svc.currentProvide.getSnapshot() + expect(added).not.toBe(before) + expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } }) + expect(added.hooks['extra']).toBe(source) + expect(notified).toHaveBeenCalledTimes(1) + dispose() + const removed = b.svc.currentProvide.getSnapshot() + expect(removed).not.toBe(added) + expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false) + expect(notified).toHaveBeenCalledTimes(2) + }) + + it('an unsubscribed currentProvide listener stops receiving notifications', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + const notified = vi.fn() + const off = b.svc.currentProvide.subscribe(notified) + off() + b.svc.open(sid('s1')) + expect(notified).not.toHaveBeenCalled() + }) + it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 97bcb50f0a..b7d6f31093 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -97,18 +97,13 @@ function fakeWorkspaces() { return { list: { getSnapshot: () => state, subscribe: () => () => undefined } } } -/** Minimal sessions face for the host seam (list observable + provide bundle). */ +/** Minimal sessions face for the host seam (list observable + current provide projection). */ function fakeSessions() { const state = { ids: [], byId: {}, current: undefined as string | undefined } + const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } return { list: { getSnapshot: () => state, subscribe: () => () => undefined }, - provideInfo: (id: string) => (id === 'known' - ? { - sessionId: id, - hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }, - props: {}, - } - : undefined), + currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => undefined }, } } @@ -232,13 +227,11 @@ describe('host face', () => { expect(host.entriesOf('t.host')).toHaveLength(0) }) - it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => { + it('exposes the session list and the atomic current provide projection', async () => { const bench = await boot() const host = captureHost(bench) expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] }) - expect(host.sessions.current.getSnapshot()).toBeUndefined() - expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' }) - expect(host.sessions.provideInfo('ghost')).toBeUndefined() + expect(host.sessions.provide.getSnapshot()).toMatchObject({ sessionId: undefined }) }) it('exposes the independent Workspace list source', async () => { diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 504d0ec8c6..37b824c906 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -87,12 +87,13 @@ async function bench() { } } const providers: TestProvider[] = [] + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const sessionsFake = { list: listStore, binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), scope: (id: SessionId) => mint(id), provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, scopeOf, sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 36a25c5b34..818a6bf620 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -32,12 +32,13 @@ async function bench() { current: undefined, phase: 'ready', }) + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const sessionsFake = { list: listStore, binding: vi.fn(), scope: () => undefined, provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: vi.fn(() => () => {}), create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index aa9451b413..d7f523b028 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) { // Provide-channel contributions land in this bundle the way the runtime // materializes them; the renderer host serves it through provideInfo. const provided: { hooks: Record; props: Record } = { hooks: {}, props: {} } + // Identity-stable currentProvide snapshot (uSES getSnapshot contract), + // materialized on first render after the provide contributions landed. + let infoCell: { sessionId: SessionId; hooks: Record; props: Record } | undefined const sessionsFake = { list, binding: (id: SessionId) => (id === SID @@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) { provideInfo: (id: string) => (id === SID ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } : undefined), - maybeProvideInfo: (id: string | undefined) => (id === SID - ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } - : { hooks: provided.hooks, props: provided.props }), + currentProvide: { + getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }, + subscribe: () => () => {}, + }, create: vi.fn(), open: vi.fn(), } diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 87e188bbbd..117a7108c9 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien const SID = 's1' as SessionId +/** Identity-stable no-session bundle (uSES getSnapshot contract). */ +const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } + afterEach(cleanup) // The chat store persists under its declared key; clear between cases. beforeEach(() => { @@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) { subscribe: (fn: () => void) => session.subscribe(fn), }, }) + const provideInfo = (id: string) => { + if (id !== SID) return undefined + if (info === undefined) { + const hooks: Record = { session } + const props: Record = {} + for (const provider of providers) { + const c = provider(bindingOf(SID)) + Object.assign(hooks, c.hooks ?? {}) + Object.assign(props, c.props ?? {}) + } + info = { sessionId: SID, hooks, props } + } + return info + } ctx.provide('sessions', { list, binding: bindingOf, scope: () => actxFake, - provideInfo: (id: string) => { - if (id !== SID) return undefined - if (info === undefined) { - const hooks: Record = { session } - const props: Record = {} - for (const provider of providers) { - const c = provider(bindingOf(SID)) - Object.assign(hooks, c.hooks ?? {}) - Object.assign(props, c.props ?? {}) - } - info = { sessionId: SID, hooks, props } - } - return info - }, - maybeProvideInfo(id: string | undefined) { - // `this` inside an object-literal method is any under strict lint; the - // fake resolves through its own provideInfo above. - /* eslint-disable-next-line @typescript-eslint/no-unsafe-return, - @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ - return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} } + provideInfo, + currentProvide: { + getSnapshot: () => provideInfo(SID), + subscribe: () => () => {}, }, provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, scopeOf: () => SID, @@ -254,7 +255,10 @@ describe('registrant load-order seam', () => { binding: () => undefined, scope: () => undefined, provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvide: { + getSnapshot: () => ABSENT_INFO, + subscribe: () => () => {}, + }, provide: () => () => {}, create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index ec50a3f317..19988eeab9 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts' const sid = (s: string): SessionId => s as SessionId +/** Identity-stable no-session bundle (uSES getSnapshot contract). */ +const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } + interface Bench { slots: SlotsService chat: ReturnType @@ -23,7 +26,10 @@ function bench(): Bench { ids: [], byId: {}, current: undefined, phase: 'ready', }), provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvide: { + getSnapshot: () => ABSENT_INFO, + subscribe: () => () => {}, + }, provide: () => () => {}, }) ctx.provide('workspaces', { diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 5b7de0d6f1..09143ca84e 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -105,18 +105,14 @@ export interface SlotRendererHost { sessions: { /** Session list source backing the useSessions standard hook. */ list: HostObservable - /** Current-session source used by SessionProvider. */ - current: HostObservable - /** Resolve a definite session bundle, or undefined when the id is unknown. */ - provideInfo(id: string): SessionProvideInfo | undefined /** - * Resolve the current-session-optional standard props bundle. The result - * always carries the static provider roster, even when `id` is absent or - * cannot resolve to a live session. - * @param id - current session id, when selected. - * @returns the optional provide info. + * Atomic current-session provide projection used by SessionProvider: + * selection changes and provider-roster changes publish through this one + * source, so a stable current id cannot strand mounted entries on an + * obsolete hook/prop schema. Carries the static roster with sessionId + * undefined while no current session resolves. */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo + provide: HostObservable } /** Workspace-side standard-kit sources. */ workspaces: { diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx index 79cb763a3e..61212e57a3 100644 --- a/packages/client/web-react/src/session-provider.tsx +++ b/packages/client/web-react/src/session-provider.tsx @@ -90,9 +90,9 @@ function useAbsentSnapshot(_selector: (snapshot: never) => S, _equal?: (a: S, */ export function SessionMaybeProvider({ children }: { children: ReactNode }) { const host = useHost() - const id = observableHook(host.sessions.current)(s => s) + const info = observableHook(host.sessions.provide)(s => s) return ( - + {children} ) @@ -107,17 +107,17 @@ export interface SessionProviderProps { } /** - * Framework-wired session area: subscribes to the host's current-session - * source, resolves the session cell, and remounts the body under - * `key={sessionId}` so a session switch rebuilds the session subtree. This - * dependency-inverted layer uses plain string ids; `PropsRuntime` applies the - * branded type at the component boundary. + * Framework-wired session area: subscribes to the host's current provide + * source and remounts the body under `key={sessionId}` so a session switch + * rebuilds the session subtree. This dependency-inverted layer uses plain + * string ids; `PropsRuntime` applies the branded type at the component + * boundary. */ export function SessionProvider({ empty, children }: SessionProviderProps) { const host = useHost() - const id = observableHook(host.sessions.current)(s => s) - const info = id === undefined ? undefined : host.sessions.provideInfo(id) - if (id === undefined || info === undefined) return <>{empty?.() ?? null} + const info = observableHook(host.sessions.provide)(s => s) + const id = info.sessionId + if (id === undefined) return <>{empty?.() ?? null} return ( {children(id)} diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx index 381170527a..09d38c187f 100644 --- a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx @@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'> /** Passthrough host over the real core (store/session seats unused here). */ function hostOver(core: SlotCore): SlotRendererHost { + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } return { subscribe: (key, fn) => core.subscribe(key, fn), getVersion: key => core.getVersion(key), @@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - current: { getSnapshot: () => undefined, subscribe: () => () => {} }, - provideInfo: () => undefined, - maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }), + provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 51b12d2385..dba09810f6 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import { act, fireEvent, render } from '@testing-library/react' import { useEffect, type ReactNode } from 'react' import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError, type RenderOpts, type SessionProvideInfo, @@ -85,7 +86,9 @@ function makeHost() { const storeCache = new Map>() const list = observable<{ ids: string[] }>({ ids: [] }) const workspaces = observable<{ ids: string[] }>({ ids: [] }) - const current = observable(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() const bump = (key: string) => { @@ -123,10 +126,7 @@ function makeHost() { }, sessions: { list, - current, - provideInfo: id => infos.get(id), - maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id)) - ?? { sessionId: undefined, hooks: {}, props: {} }, + provide, }, workspaces: { list: workspaces }, } @@ -134,7 +134,14 @@ function makeHost() { host, list, workspaces, - current, + // Same driver surface as the old current cell: set(id) publishes the + // resolved bundle (or the absent projection) through the provide source. + current: { + set: (id: string | undefined) => { + currentId = id + provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + }, + }, declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) }, add: (key: string, partial: Omit & { options?: StoredEntry['options'] }) => { const entry = entryOf(partial) @@ -161,6 +168,7 @@ function makeHost() { props: {}, } infos.set(id, info) + if (currentId === id) provide.set(info) return info }, } diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.spec.tsx index 2e055bcee2..1dacdcec53 100644 --- a/packages/client/web-react/tests/session-provider.spec.tsx +++ b/packages/client/web-react/tests/session-provider.spec.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef } from 'react' import { describe, expect, it, vi } from 'vitest' import { act, render } from '@testing-library/react' -import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer, SessionProvider, type SessionProvideInfo, type SlotRendererHost, @@ -26,12 +26,14 @@ function observable(initial: T) { } /** - * Minimal host: SessionProvider only reads sessions.current/cell, but it must + * Minimal host: SessionProvider only reads sessions.provide, but it must * render inside the renderer tree (HostContext), so the harness mounts a real * root entry whose body is the test's render-prop provider. */ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) { - const current = observable(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() const sessionEntries: StoredEntry[] = [] const rootEntry: StoredEntry = { @@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea storeOf: () => undefined, sessions: { list: observable({ ids: [] }), - current, - provideInfo: id => infos.get(id), - maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id)) - ?? { sessionId: undefined, hooks: { session: undefined }, props: {} }, + provide, }, workspaces: { list: observable({ items: [] }) }, } return { host, - current, + // Same driver surface as the old current cell: set(id) publishes the + // resolved bundle (or the absent projection) through the provide source. + current: { + set: (id: string | undefined) => { + currentId = id + provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + }, + }, addSession: (id: string) => { // Bare source per bundle (identity-stable): the machinery binds useSession from it. const info: SessionProvideInfo = { @@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea props: {}, } infos.set(id, info) + if (currentId === id) provide.set(info) return info }, + /** Swap one session's bundle in place (roster-change stand-in); republish when current. */ + replaceSession: (info: SessionProvideInfo) => { + infos.set(info.sessionId, info) + if (currentId === info.sessionId) provide.set(info) + }, registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) }, } } @@ -149,6 +161,28 @@ describe('SessionProvider', () => { expect(seen.at(-1)!['sessionId']).toBe('s2') }) + it('republishes a mounted session entry when its provide bundle changes under the same id', () => { + const seen: unknown[] = [] + const h = makeHost({ + root: renderSlot => {() => renderSlot('k.session', {})}, + }) + const original = h.addSession('s1') + h.registerSession({ + component: (props: { feature?: string }) => { + seen.push(props.feature) + return null + }, + options: {}, + }) + render(<>{createSlotRenderer().renderRoot(h.host, {})}) + act(() => { h.current.set('s1') }) + expect(seen.at(-1)).toBeUndefined() + // A provider-roster change rematerializes the bundle; the provide source + // must carry it to already-mounted entries without a selection change. + act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) }) + expect(seen.at(-1)).toBe('now-live') + }) + it('fails loud when mounted outside the renderer tree (no host channel)', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => render( diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.spec.tsx index 6c3e5d194b..f0fa07fd44 100644 --- a/packages/client/web-react/tests/stale-authorization.spec.tsx +++ b/packages/client/web-react/tests/stale-authorization.spec.tsx @@ -21,6 +21,7 @@ function makeHost() { const versions = new Map() const subs = new Map void>>() const live = new Set() + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) for (const fn of [...(subs.get(key) ?? [])]) fn() @@ -39,9 +40,7 @@ function makeHost() { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - current: { getSnapshot: () => undefined, subscribe: () => () => {} }, - provideInfo: () => undefined, - maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }), + provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, From eae712409b27e93f5379c2d7813c82b5f2998ba9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:54:08 +0800 Subject: [PATCH 21/33] fix(ui-settings): subscribe to the section ledger through useSyncExternalStore The manual useState+useEffect subscription could miss a registration landing between render and effect commit; uSES closes that window and keeps the same version-dedupe behavior. --- .../client/ui-settings/src/client/SettingsRoot.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 0d12135311..22946e799a 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -7,7 +7,7 @@ * aria-labelledby the title node; close: visually-hidden slot text). Modal * open state and the active section id are component-local viewing state. */ -import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps } from './contract/slots.ts' @@ -99,12 +99,10 @@ export function SettingsRoot(props: SettingsRootComponentProps) { // The ledger tick keeps the nav rows fresh: registrants re-register with // freshly localized text on locale change, and the trigger/header/close // seats re-render through their own outlets' subscriptions. - // State = ledger version: same-version notifications dedupe to no render. - const [, setSectionsRev] = useState(() => sectionsVersion()) - useEffect( - () => subscribeSections(() => { setSectionsRev(sectionsVersion()) }), - [subscribeSections, sectionsVersion], - ) + // uSES over the ledger version: same-version notifications dedupe to no + // render, and a registration landing between render and effect + // subscription cannot be missed. + useSyncExternalStore(subscribeSections, sectionsVersion) const rows = sections() return ( From d3d01cb49cd07674507a12a71c4865c948f716e8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:54:24 +0800 Subject: [PATCH 22/33] fix(ui-slash): make the reference lexicon reactive end to end The decoration scan read a mutable lexicon() aggregation during render with no subscription, so a catalog settling or a child spawning after prewarm left drafted tokens undecorated until an unrelated re-render. The controller now publishes the aggregation as a snapshot store fed by a new optional SlashSource.subscribeLexicon hook (ui-skill notifies on settle/invalidate, ui-subagent forwards the session-list feed), the composer keyboard face exposes it as an observable, and InputBar subscribes through uSES. Sources registered after scope birth now warm and join live controllers via a service broadcast. --- .../src/client/input/contract.ts | 6 +- .../src/client/input/facade.ts | 11 +-- .../src/client/skeleton/InputBar.tsx | 7 +- .../ui-conversation/tests/input-bar.spec.tsx | 6 +- packages/client/ui-skill/src/client/index.ts | 22 +++++- .../ui-skill/tests/browser-plugin.spec.ts | 27 +++++++ .../client/ui-slash/src/client/controller.ts | 57 ++++++++++++--- .../client/ui-slash/src/client/service.ts | 4 +- packages/client/ui-slash/src/types.ts | 10 +++ .../client/ui-slash/tests/service.spec.ts | 71 ++++++++++++++++++- .../client/ui-subagent/src/client/index.ts | 4 ++ .../ui-subagent/tests/browser-plugin.spec.ts | 37 ++++++++-- 12 files changed, 232 insertions(+), 30 deletions(-) diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 8a4d2905db..c229abf18c 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -5,7 +5,7 @@ * conversation wiring layer alone sees the full SessionInput. InputMachine * (machine.ts) is package-private and never exported. */ -import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, @@ -99,8 +99,8 @@ export interface ComposerKeyboard { space(): boolean /** Dismiss the popupSelect shell (any interaction outside the box). */ dismissPopup(): void - /** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */ - lexicon(): ReadonlyMap<'/' | '@', readonly string[]> + /** Hot plain-text reference lexicon source for the decoration scan (decision 21; empty Map without a pipeline). */ + readonly lexicon: ObservableSnapshot> } /** One queued-message row projected from the session/queued frames (T9 supplies the store). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index f3f6dd7451..d6b2fde80a 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput { } /** - * Hot plain-text reference lexicons for the decoration scan (decision 21). - * @returns the controller's per-trigger aggregation; empty Map without a pipeline. + * Hot plain-text reference lexicon source for the decoration scan + * (decision 21): delegates to the controller's aggregated store. Stable + * identity per shell; without a pipeline the snapshot is the empty Map and + * subscribers never fire. */ - lexicon(): ReadonlyMap<'/' | '@', readonly string[]> { - return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON + readonly lexicon: ObservableSnapshot> = { + getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON, + subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}), } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f2313d798b..aa224d1ec1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -36,6 +36,11 @@ export function InputBar({ (fn: () => void) => noticeStore.subscribe(fn), () => noticeStore.getSnapshot(), ) + const lexiconStore = keyboard.lexicon + const lexicon = useSyncExternalStore( + (fn: () => void) => lexiconStore.subscribe(fn), + () => lexiconStore.getSnapshot(), + ) const promptError = useSession(s => s.promptError) const running = useSession(s => s.running) const disabled = useSession(s => s.removed) @@ -244,7 +249,7 @@ export function InputBar({ // claim token highlights through behind the textarea glyphs; each U+FFFC // placeholder renders as a chip (the textarea's own glyph is invisible, the // backdrop chip supplies the visual); the claim hint is ghost text. - const deco = deriveDecorations(input, keyboard.lexicon()) + const deco = deriveDecorations(input, lexicon) const backdrop: ReactNode[] = [] { // Segment boundaries: the token range end, every chip offset, and every diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c50a35110a..ab62af80bb 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -56,7 +56,11 @@ function bench(over?: BenchOptions) { // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). ...(lex !== undefined - ? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable } + ? { + slash: (() => ({ + lexicon: { getSnapshot: () => lex, subscribe: () => () => {} }, + })) as unknown as NonNullable, + } : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 677843c844..7226f45163 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -44,6 +44,12 @@ export function apply(ctx: ClientContext): void { // Session-keyed catalog cache; single-flight per key. Plugin-closure state: // the fiber effect below is its teardown boundary. const fetches = new Map() + // Per-session lexicon invalidation listeners (subscribeLexicon consumers). + const lexiconListeners = new Map void>>() + + const notifyLexicon = (sessionId: SessionId): void => { + for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) listener() + } const fetchCatalog = (sessionId: SessionId): Promise => { const existing = fetches.get(sessionId) @@ -58,7 +64,10 @@ export function apply(ctx: ClientContext): void { fetches.set(sessionId, entry) promise.then( // Settled snapshot backs the synchronous lexicon reads. - (skills) => { entry.settled = skills }, + (skills) => { + entry.settled = skills + notifyLexicon(sessionId) + }, // A failed fetch must not poison the key: the next consumer retries. () => { if (fetches.get(sessionId) === entry) fetches.delete(sessionId) @@ -72,6 +81,7 @@ export function apply(ctx: ClientContext): void { if (entry === undefined) return fetches.delete(key) entry.abort.abort() + notifyLexicon(key) } const clearAll = (): void => { @@ -97,6 +107,16 @@ export function apply(ctx: ClientContext): void { lexicon(session) { return fetches.get(session.sessionId)?.settled?.map(skill => skill.name) }, + subscribeLexicon(session, listener) { + const key = session.sessionId + const listeners = lexiconListeners.get(key) ?? new Set() + listeners.add(listener) + lexiconListeners.set(key, listeners) + return () => { + listeners.delete(listener) + if (listeners.size === 0) lexiconListeners.delete(key) + } + }, onPick({ candidate }) { // Decision 21: plain-text reference — the literal lands in the draft // and ships to the model verbatim (trailing space closes the token). diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 11f53e142c..0d8f2c57cd 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -208,6 +208,33 @@ describe('lexicon', () => { // Another session's key is independent — cold until its own fetch. expect(source.lexicon!(proj('s2'))).toBeUndefined() }) + + it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => { + const { list } = countingList() + const { ctx, source } = await bench(list) + const s1 = vi.fn() + const s2 = vi.fn() + source.subscribeLexicon!(proj('s1'), s1) + source.subscribeLexicon!(proj('s2'), s2) + await source.candidates(proj('s1'), req('')) + expect(s1).toHaveBeenCalledTimes(1) + expect(s2).not.toHaveBeenCalled() + // Reset invalidates every cached session: each key notifies its own listeners. + await source.candidates(proj('s2'), req('')) + ctx.emit('connection/reset') + expect(s1).toHaveBeenCalledTimes(2) + expect(s2).toHaveBeenCalledTimes(2) + }) + + it('an unsubscribed lexicon listener stops receiving notifications', async () => { + const { list } = countingList() + const { source } = await bench(list) + const listener = vi.fn() + const off = source.subscribeLexicon!(proj('s1'), listener) + off() + await source.candidates(proj('s1'), req('')) + expect(listener).not.toHaveBeenCalled() + }) }) describe('pick and codec', () => { diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index d3d3567e4d..9e95dbdd41 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -40,18 +40,35 @@ export interface SlashControllerDeps { export class SlashController { /** Menu state store (per-session; survives session switches, dies with the scope). */ readonly menu: SnapshotStore = createSnapshotStore(MENU_CLOSED) + /** + * Aggregated hot reference lexicon, grouped by trigger (decision 21): + * sources implementing the lexicon hook are polled with the session + * projection; undefined answers (roll not hot yet) are skipped; multiple + * sources on one trigger concatenate in registration order. A snapshot + * store because rolls change asynchronously (catalog settles, children + * spawn/exit) — render-side consumers subscribe instead of re-reading a + * mutable answer. + */ + readonly lexicon: SnapshotStore> = + createSnapshotStore>(new Map()) /** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */ private hit: TriggerHit | null = null private fetch: AbortController | null = null private disposed = false + /** Per-source lexicon unsubscribers (sources without the hook never enter). */ + private readonly lexiconOffs = new Map void>() constructor(private readonly deps: SlashControllerDeps) { // Scope-birth prewarm: sessions are always agent-backed, so the one-time // roster warm here replaces the projection-transition watch — there are // no capability steps to react to. const projection = this.project() - for (const src of deps.roster.all()) src.warm?.(projection) + for (const src of deps.roster.all()) { + src.warm?.(projection) + this.watchLexicon(src, projection) + } + this.refreshLexicon() } /** @@ -220,6 +237,23 @@ export class SlashController { if (state.open && state.hit !== null && state.hit.trigger === source.trigger) { this.reduce({ type: 'source-failed', generation: state.generation, source: source.name }) } + this.lexiconOffs.get(source)?.() + this.lexiconOffs.delete(source) + this.refreshLexicon() + } + + /** + * Admit a source registered after this controller's birth (root registry + * change notification): warm it and fold its roll into the live lexicon — + * the constructor-time prewarm covers only the roster present at scope + * birth. + * @param source - the newly registered source. + */ + sourceAdded(source: SlashSource): void { + const projection = this.project() + source.warm?.(projection) + this.watchLexicon(source, projection) + this.refreshLexicon() } /** Scope teardown: close and abort (the service deletes the map entry). */ @@ -228,6 +262,8 @@ export class SlashController { this.stopFetch() this.reduce({ type: 'close' }) this.hit = null + for (const off of this.lexiconOffs.values()) off() + this.lexiconOffs.clear() } /** The session projection handed to sources (agent-backed identity; constant per scope). */ @@ -248,15 +284,8 @@ export class SlashController { return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true } - /** - * Aggregate the sources' plain-text reference lexicons (decision 21), - * grouped by trigger: sources implementing the hook are polled with the - * session projection (onSpace's poll pattern); undefined answers (roll not - * hot yet) are skipped; multiple sources on one trigger concatenate in - * registration order. - * @returns trigger → decorated-name roll for the decoration scan. - */ - lexicon(): ReadonlyMap { + /** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */ + private refreshLexicon(): void { const projection = this.project() const rolls = new Map() for (const src of this.deps.roster.all()) { @@ -266,7 +295,13 @@ export class SlashController { const prev = rolls.get(src.trigger) rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names]) } - return rolls + this.lexicon.set(rolls) + } + + /** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */ + private watchLexicon(source: SlashSource, projection: ClientSessionContext): void { + if (source.lexicon === undefined || source.subscribeLexicon === undefined) return + this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() })) } /** Launch the candidate fetch for one hit generation, superseding the previous one. */ diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index 094f325393..0ca3b91c2a 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract { } /** - * Register one trigger source. + * Register one trigger source. Live session controllers are notified so a + * source arriving after scope birth still warms and joins the lexicon. * @param src - the source; (trigger, name) must be unique — duplicates throw. * @returns the disposer (callers wrap registration in ctx.effect). Disposal * while a controller shows the source's menu group drops that group. @@ -49,6 +50,7 @@ export class SlashService extends Service implements SlashServiceContract { throw new Error(`slash source "${src.trigger}${src.name}" is already registered`) } live.sources.push(src) + for (const controller of live.controllers.values()) controller.sourceAdded(src) return () => { const at = live.sources.indexOf(src) if (at < 0) return diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index 2fb26fa4b6..4b9bd64408 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -165,6 +165,16 @@ export interface SlashSource { * (the render path must stay synchronous and side-effect free). */ lexicon?(session: ClientSessionContext): readonly string[] | undefined + /** + * Subscribe to changes of this source's {@link SlashSource.lexicon} answer + * for one session (backing data settled, invalidated, or refreshed). The + * controller re-polls lexicon on each notification; a source whose roll + * never changes after warm omits the hook. + * @param session - stable session projection. + * @param listener - invalidation callback. + * @returns unsubscribe. + */ + subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void /** Reference codec; required for sources producing insert outcomes. */ readonly codec?: ReferenceCodec } diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index 379d7bbe8a..099e2bb4f5 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -126,6 +126,18 @@ describe('registerSource', () => { slash.registerSource(deferredSource('/', 'beta').source) }) + it('a source registered after controller birth warms in every live controller', async () => { + const { slash, mint } = await serviceBench() + const ca = slash.sessionOf(mint('a').actx) + const cb = slash.sessionOf(mint('b').actx) + const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] }) + slash.registerSource(late.source) + expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') }) + expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') }) + expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh']) + expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh']) + }) + it('HMR shape: dispose of the registering fiber removes the source', async () => { const { root, slash, mint } = await serviceBench() const controller = slash.sessionOf(mint('a').actx) @@ -513,7 +525,7 @@ describe('lexicon', () => { skill, lexSource('@', 'subagent', ['worker-1']), ]) - const rolls = controller.lexicon() + const rolls = controller.lexicon.getSnapshot() expect([...rolls.keys()]).toEqual(['/', '@']) expect(rolls.get('/')).toEqual(['commit-helper', 'review']) expect(rolls.get('@')).toEqual(['worker-1']) @@ -522,7 +534,7 @@ describe('lexicon', () => { it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => { const { controller } = controllerBench([lexSource('/', 'skill', undefined)]) - expect(controller.lexicon().size).toBe(0) + expect(controller.lexicon.getSnapshot().size).toBe(0) }) it('two sources on one trigger concatenate in registration order', () => { @@ -531,10 +543,63 @@ describe('lexicon', () => { lexSource('/', 'prompt', ['c']), lexSource('@', 'subagent', undefined), // not hot: '@' stays absent ]) - const rolls = controller.lexicon() + const rolls = controller.lexicon.getSnapshot() expect(rolls.get('/')).toEqual(['b', 'a', 'c']) expect(rolls.has('@')).toBe(false) }) + + it('a source lexicon notification republishes the aggregated store', () => { + let roll: readonly string[] | undefined = undefined + let notify: (() => void) | undefined + const source: SlashSource = { + trigger: '/', + name: 'skill', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + lexicon: () => roll, + subscribeLexicon: (_session, listener) => { + notify = listener + return () => { notify = undefined } + }, + } + const { controller } = controllerBench([source]) + expect(controller.lexicon.getSnapshot().size).toBe(0) + const seen: number[] = [] + controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) }) + roll = ['commit-helper'] + notify?.() + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper']) + expect(seen).toEqual([1]) + controller.dispose() + expect(notify).toBeUndefined() + }) + + it('a source registered after scope birth is warmed and folded into the live lexicon', () => { + const { controller, sources } = controllerBench([]) + expect(controller.lexicon.getSnapshot().size).toBe(0) + const warm = vi.fn() + const late: SlashSource = { + trigger: '/', + name: 'late', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + warm, + lexicon: () => ['fresh'], + } + sources.push(late) + controller.sourceAdded(late) + expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') }) + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh']) + }) + + it('a removed source leaves the aggregated lexicon', () => { + const src = lexSource('/', 'skill', ['gone']) + const { controller, sources } = controllerBench([src]) + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone']) + sources.splice(sources.indexOf(src), 1) + controller.sourceRemoved(src) + expect(controller.lexicon.getSnapshot().size).toBe(0) + }) }) describe('arbitrate', () => { diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 3ad1543c68..4170f8e339 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -39,6 +39,10 @@ export function apply(ctx: ClientContext): void { // The list snapshot is always warm — the full running-children roster. return childLabels(session, '') }, + subscribeLexicon(_session, listener) { + // The roll derives from the list snapshot, so its change feed IS the list's. + return sessions.list.subscribe(listener) + }, onPick({ candidate }) { // Decision 21: plain-text reference — the literal lands in the draft // and ships to the model verbatim (trailing space closes the token). diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index fc74470406..d138295276 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -32,17 +32,31 @@ function sessionsWith(sessions: SessionSummary[]) { const byId: Record = {} for (const s of sessions) byId[s.id] = s const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState - return { list: { getSnapshot: () => snapshot } } + const subs = new Set<() => void>() + return { + list: { + getSnapshot: () => snapshot, + subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } }, + }, + notify: () => { for (const fn of [...subs]) fn() }, + listenerCount: () => subs.size, + } } -/** Boot the plugin over fake slash/sessions faces; returns the captured source. */ -async function bench(sessions: SessionSummary[]): Promise { +/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */ +async function fullBench(sessions: SessionSummary[]) { const ctx = new Context() let captured: SlashSource | undefined + const face = sessionsWith(sessions) ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('sessions', sessionsWith(sessions)) + ctx.provide('sessions', face) await ctx.plugin({ inject: [...inject], apply }).await() - return captured! + return { source: captured!, face } +} + +/** Source-only bench for the behavior-contract suites. */ +async function bench(sessions: SessionSummary[]): Promise { + return (await fullBench(sessions)).source } const FAMILY: SessionSummary[] = [ @@ -113,6 +127,19 @@ describe('lexicon', () => { expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout']) expect(source.lexicon!(proj('childless'))).toEqual([]) }) + + it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => { + const { source, face } = await fullBench(FAMILY) + let notified = 0 + const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 }) + expect(face.listenerCount()).toBe(1) + face.notify() + expect(notified).toBe(1) + off() + expect(face.listenerCount()).toBe(0) + face.notify() + expect(notified).toBe(1) + }) }) describe('pick and codec', () => { From bce9910b47c54e33c5f9bdd74810a22c01fdd461 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:54:47 +0800 Subject: [PATCH 23/33] docs: regenerate cordis catalog line anchors --- docs/cordis-catalog/events.md | 8 ++++---- docs/event-producer-consumer.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 86a857cd17..ca0e9b82fc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -658,7 +658,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:230`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-consume-token` — bail @@ -674,7 +674,7 @@ Consumes one command token after business success (popup settle / menu-pick exec 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:244`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-reference` — bail @@ -690,7 +690,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:237`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-text` — bail @@ -707,7 +707,7 @@ Replaces the trigger token span with literal text — the plain-text reference p 'slash/input-insert-text'(request: InsertTextRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:252`](../../packages/client/ui-slash/src/types.ts) ## `subagent/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 18ca96e1b3..3a72d5c0f9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,10 +35,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:252`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | From 71529aa7d22d258095bd189f7c0437e821217d7f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:03:56 +0800 Subject: [PATCH 24/33] refactor(client): rename the host provide source to provideInfo --- packages/client/runtime/src/client/slots.ts | 2 +- packages/client/runtime/tests/slots-service.spec.ts | 2 +- packages/client/ui-slots/src/renderer.ts | 2 +- packages/client/web-react/src/session-provider.tsx | 4 ++-- .../client/web-react/tests/scoped-slots-real-core.spec.tsx | 2 +- packages/client/web-react/tests/scoped-slots.spec.tsx | 2 +- packages/client/web-react/tests/session-provider.spec.tsx | 4 ++-- packages/client/web-react/tests/stale-authorization.spec.tsx | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index ed10826b9d..413668b2f8 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -256,7 +256,7 @@ export class SlotsService extends Service { entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), sessions: { list: sessions.list, - provide: sessions.currentProvide, + provideInfo: sessions.currentProvide, }, workspaces: { list: workspaces.list }, } diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index b7d6f31093..e9d6d3e12b 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -231,7 +231,7 @@ describe('host face', () => { const bench = await boot() const host = captureHost(bench) expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] }) - expect(host.sessions.provide.getSnapshot()).toMatchObject({ sessionId: undefined }) + expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined }) }) it('exposes the independent Workspace list source', async () => { diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 09143ca84e..4a437887d1 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -112,7 +112,7 @@ export interface SlotRendererHost { * obsolete hook/prop schema. Carries the static roster with sessionId * undefined while no current session resolves. */ - provide: HostObservable + provideInfo: HostObservable } /** Workspace-side standard-kit sources. */ workspaces: { diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx index 61212e57a3..a9679e460c 100644 --- a/packages/client/web-react/src/session-provider.tsx +++ b/packages/client/web-react/src/session-provider.tsx @@ -90,7 +90,7 @@ function useAbsentSnapshot(_selector: (snapshot: never) => S, _equal?: (a: S, */ export function SessionMaybeProvider({ children }: { children: ReactNode }) { const host = useHost() - const info = observableHook(host.sessions.provide)(s => s) + const info = observableHook(host.sessions.provideInfo)(s => s) return ( {children} @@ -115,7 +115,7 @@ export interface SessionProviderProps { */ export function SessionProvider({ empty, children }: SessionProviderProps) { const host = useHost() - const info = observableHook(host.sessions.provide)(s => s) + const info = observableHook(host.sessions.provideInfo)(s => s) const id = info.sessionId if (id === undefined) return <>{empty?.() ?? null} return ( diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx index 09d38c187f..6649018b0b 100644 --- a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx @@ -36,7 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, + provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index dba09810f6..36b99a6ef8 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -126,7 +126,7 @@ function makeHost() { }, sessions: { list, - provide, + provideInfo: provide, }, workspaces: { list: workspaces }, } diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.spec.tsx index 1dacdcec53..c1f1f648a2 100644 --- a/packages/client/web-react/tests/session-provider.spec.tsx +++ b/packages/client/web-react/tests/session-provider.spec.tsx @@ -26,7 +26,7 @@ function observable(initial: T) { } /** - * Minimal host: SessionProvider only reads sessions.provide, but it must + * Minimal host: SessionProvider only reads sessions.provideInfo, but it must * render inside the renderer tree (HostContext), so the harness mounts a real * root entry whose body is the test's render-prop provider. */ @@ -51,7 +51,7 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea storeOf: () => undefined, sessions: { list: observable({ ids: [] }), - provide, + provideInfo: provide, }, workspaces: { list: observable({ items: [] }) }, } diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.spec.tsx index f0fa07fd44..df3bc51d2b 100644 --- a/packages/client/web-react/tests/stale-authorization.spec.tsx +++ b/packages/client/web-react/tests/stale-authorization.spec.tsx @@ -40,7 +40,7 @@ function makeHost() { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - provide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, + provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, From b7f3cd3d789e531e2ee72659f928c7ffdb38aaf1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:09:55 +0800 Subject: [PATCH 25/33] refactor(client): rename currentProvide to currentProvideInfo --- .../runtime/src/client/sessions/service.ts | 28 +++++++++---------- packages/client/runtime/src/client/slots.ts | 2 +- .../runtime/tests/sessions-service.spec.ts | 24 ++++++++-------- .../runtime/tests/slots-service.spec.ts | 2 +- .../tests/apply-inject.spec.tsx | 2 +- .../ui-conversation/tests/chat-apply.spec.tsx | 2 +- .../tests/chat-code-subcalls.spec.tsx | 4 +-- .../tests/chat-toolview-slot.spec.tsx | 4 +-- .../tests/selection-survival.spec.ts | 2 +- 9 files changed, 35 insertions(+), 35 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 03eca7724f..b5ce1905eb 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -157,7 +157,7 @@ export class SessionsService { * host's `sessions.provide` feed), so a roster change under a stable * current id republishes the bundle instead of stranding mounted entries. */ - readonly currentProvide: HostObservable + readonly currentProvideInfo: HostObservable /** * Persisted selection cell (the durable half of `list.current`). Private on @@ -174,10 +174,10 @@ export class SessionsService { private readonly providers: SessionProvideDescriptor[] = [] /** Static no-session projection, rebuilt only when the provider roster changes. */ private maybeInfo: SessionMaybeProvideInfo - /** Latest published {@link SessionsService.currentProvide} bundle (identity comparison dedupes republish). */ - private currentProvideSnapshot: SessionMaybeProvideInfo - /** currentProvide subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */ - private readonly currentProvideListeners = new Set<() => void>() + /** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */ + private currentProvideInfoSnapshot: SessionMaybeProvideInfo + /** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */ + private readonly currentProvideInfoListeners = new Set<() => void>() /** * The staged session id — follows `list.current` exactly, holding its last * defined value across masked gaps (a transiently absent selection blanks @@ -221,12 +221,12 @@ export class SessionsService { resolve: binding => ({ hooks: { session: binding.session } }), }) this.maybeInfo = this.materializeMaybeProvideInfo() - this.currentProvideSnapshot = this.maybeInfo - this.currentProvide = { - getSnapshot: () => this.currentProvideSnapshot, + this.currentProvideInfoSnapshot = this.maybeInfo + this.currentProvideInfo = { + getSnapshot: () => this.currentProvideInfoSnapshot, subscribe: (fn) => { - this.currentProvideListeners.add(fn) - return () => { this.currentProvideListeners.delete(fn) } + this.currentProvideInfoListeners.add(fn) + return () => { this.currentProvideInfoListeners.delete(fn) } }, } rootCtx.reflect.provide('sessions', this, undefined) @@ -272,9 +272,9 @@ export class SessionsService { */ private projectCurrentProvide(): void { const next = this.maybeProvideInfo(this.list.getSnapshot().current) - if (next === this.currentProvideSnapshot) return - this.currentProvideSnapshot = next - for (const fn of [...this.currentProvideListeners]) fn() + if (next === this.currentProvideInfoSnapshot) return + this.currentProvideInfoSnapshot = next + for (const fn of [...this.currentProvideInfoListeners]) fn() } /** Build the static no-session kit and reject duplicate declared names. */ @@ -443,7 +443,7 @@ export class SessionsService { /** * Resolve one session's render-layer standard-props bundle (ctx never * enters the render layer; the renderer subscribes to - * {@link SessionsService.currentProvide}). Pure resolution — render-safe: + * {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe: * no staging, no window side effects (StrictMode double-invokes and * concurrent discarded passes must stay free). * @param id - session id. diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 413668b2f8..d18377e052 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -256,7 +256,7 @@ export class SlotsService extends Service { entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), sessions: { list: sessions.list, - provideInfo: sessions.currentProvide, + provideInfo: sessions.currentProvideInfo, }, workspaces: { list: workspaces.list }, } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 2f90c1c301..b97dac3d78 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -195,55 +195,55 @@ describe('cell (render-layer session kit)', () => { expect(b.svc.provideInfo('ghost')).toBeUndefined() }) - it('currentProvide follows selection: absent projection ↔ definite bundle, notified on each move', async () => { + it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) - const absent = b.svc.currentProvide.getSnapshot() + const absent = b.svc.currentProvideInfo.getSnapshot() expect(absent.sessionId).toBeUndefined() expect(Object.hasOwn(absent.hooks, 'session')).toBe(true) const notified = vi.fn() - b.svc.currentProvide.subscribe(notified) + b.svc.currentProvideInfo.subscribe(notified) b.svc.open(sid('s1')) - expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s1')) + expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s1')) expect(notified).toHaveBeenCalledTimes(1) b.svc.open(sid('s2')) - expect(b.svc.currentProvide.getSnapshot()).toBe(b.svc.provideInfo('s2')) + expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s2')) expect(notified).toHaveBeenCalledTimes(2) b.svc.clear() await Promise.resolve() // clearSelection projects through the manager notifier - expect(b.svc.currentProvide.getSnapshot().sessionId).toBeUndefined() + expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined() }) it('a provider roster change under a stable current id republishes the bundle', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) b.svc.open(sid('s1')) - const before = b.svc.currentProvide.getSnapshot() + const before = b.svc.currentProvideInfo.getSnapshot() const notified = vi.fn() - b.svc.currentProvide.subscribe(notified) + b.svc.currentProvideInfo.subscribe(notified) const source = { getSnapshot: () => 'live', subscribe: () => () => {} } const dispose = b.svc.provide({ hooks: ['extra'], props: ['marker'], resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }), }) - const added = b.svc.currentProvide.getSnapshot() + const added = b.svc.currentProvideInfo.getSnapshot() expect(added).not.toBe(before) expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } }) expect(added.hooks['extra']).toBe(source) expect(notified).toHaveBeenCalledTimes(1) dispose() - const removed = b.svc.currentProvide.getSnapshot() + const removed = b.svc.currentProvideInfo.getSnapshot() expect(removed).not.toBe(added) expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false) expect(notified).toHaveBeenCalledTimes(2) }) - it('an unsubscribed currentProvide listener stops receiving notifications', async () => { + it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) const notified = vi.fn() - const off = b.svc.currentProvide.subscribe(notified) + const off = b.svc.currentProvideInfo.subscribe(notified) off() b.svc.open(sid('s1')) expect(notified).not.toHaveBeenCalled() diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index e9d6d3e12b..07e03b6e9d 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -103,7 +103,7 @@ function fakeSessions() { const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } return { list: { getSnapshot: () => state, subscribe: () => () => undefined }, - currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => undefined }, + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined }, } } diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 37b824c906..885cdd2523 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -93,7 +93,7 @@ async function bench() { binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), scope: (id: SessionId) => mint(id), provideInfo: () => undefined, - currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, scopeOf, sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 818a6bf620..dab04e94c7 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -38,7 +38,7 @@ async function bench() { binding: vi.fn(), scope: () => undefined, provideInfo: () => undefined, - currentProvide: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: vi.fn(() => () => {}), create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index d7f523b028..5c40f282db 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -87,7 +87,7 @@ async function bench(snapshot: ConversationSnapshot) { // Provide-channel contributions land in this bundle the way the runtime // materializes them; the renderer host serves it through provideInfo. const provided: { hooks: Record; props: Record } = { hooks: {}, props: {} } - // Identity-stable currentProvide snapshot (uSES getSnapshot contract), + // Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract), // materialized on first render after the provide contributions landed. let infoCell: { sessionId: SessionId; hooks: Record; props: Record } | undefined const sessionsFake = { @@ -106,7 +106,7 @@ async function bench(snapshot: ConversationSnapshot) { provideInfo: (id: string) => (id === SID ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } : undefined), - currentProvide: { + currentProvideInfo: { getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }, subscribe: () => () => {}, }, diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 117a7108c9..ec713d4bb1 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -111,7 +111,7 @@ async function bench(nodes: ToolResultNode[]) { binding: bindingOf, scope: () => actxFake, provideInfo, - currentProvide: { + currentProvideInfo: { getSnapshot: () => provideInfo(SID), subscribe: () => () => {}, }, @@ -255,7 +255,7 @@ describe('registrant load-order seam', () => { binding: () => undefined, scope: () => undefined, provideInfo: () => undefined, - currentProvide: { + currentProvideInfo: { getSnapshot: () => ABSENT_INFO, subscribe: () => () => {}, }, diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index 19988eeab9..533d5ce730 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -26,7 +26,7 @@ function bench(): Bench { ids: [], byId: {}, current: undefined, phase: 'ready', }), provideInfo: () => undefined, - currentProvide: { + currentProvideInfo: { getSnapshot: () => ABSENT_INFO, subscribe: () => () => {}, }, From b5168bbf865b827050dae04e1c6fa049eda9c8bd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:08:24 +0800 Subject: [PATCH 26/33] feat(ui-slots): bind inject hooks compartments into use selector hooks Registrant-private reactive facts previously reached components as raw observables that each component subscribed by hand (InputBar notices/ lexicon via uSES, SettingsRoot via a version/subscribe/getter triple). The inject face now carries a reserved hooks compartment of bare sources; the renderer binds each into a use selector hook through the same machinery as the provide channel, so components consume useNotices/useLexicon/useSections and never see a subscription primitive. InputBar and SettingsRoot are the first two consumers. --- ...2-slot-type-chain-implementation.i18n.yaml | 6 +-- ...26-07-22-slot-type-chain-implementation.md | 8 ++-- ...07-22-slot-type-chain-implementation.zh.md | 8 ++-- packages/client/AGENTS.md | 4 +- .../ui-conversation/src/client/apply.ts | 4 +- .../src/client/contract/slots.ts | 17 ++++++--- .../src/client/input/contract.ts | 6 +-- .../src/client/skeleton/InputBar.tsx | 19 +++------- .../ui-conversation/tests/input-bar.spec.tsx | 2 + .../tests/input-matrix.spec.tsx | 2 + .../tests/input-scenarios.spec.tsx | 2 + .../ui-conversation/tests/skeleton.spec.tsx | 2 + .../ui-settings/src/client/SettingsRoot.tsx | 14 +++---- .../ui-settings/src/client/contract/slots.ts | 30 +++++++++------ .../client/ui-settings/src/client/index.ts | 38 +++++++++++++------ .../client/ui-settings/tests/apply.spec.ts | 13 ++++--- .../ui-settings/tests/settings-root.spec.tsx | 19 ++++++---- packages/client/ui-slots/src/index.ts | 36 ++++++++++++++++-- .../client/web-react/src/scoped-slots.tsx | 25 ++++++++++-- .../web-react/tests/scoped-slots.spec.tsx | 19 ++++++++++ 20 files changed, 185 insertions(+), 89 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index eacbd89847..1604914ac0 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8 -2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6 +2026-07-22-slot-type-chain-implementation.zh.md: 90473861c199f326f4b3635580c885517f0d612b diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index 617524475f..e88361701f 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -45,7 +45,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child | runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` | | child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` | | store | `PropsStore` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) | -| business | `I` | inject return type | plain data + callbacks (hooks banned) | +| business | `I` | inject return type | plain data + callbacks; a reserved `hooks` compartment of bare observables arrives bound as `use` selector hooks (`InjectFace`) | `sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API. @@ -80,11 +80,11 @@ Store scope is **derived from the mounting entry's scope** (session slot → one ### inject: the registrant's business face, on its own ctx -An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape. +An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks, plus at most the reserved `hooks` compartment: a map of bare observable sources (getSnapshot+subscribe) the renderer binds into `use` selector hooks before the face reaches the component — the registrant-private twin of the provide channel's hooks compartment, for reactive facts too niche for the global standard kit (composer notices/lexicon, the settings nav rows). Components never receive the raw sources, so business code still contains no subscription machinery. Everything else stays plain: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hand-made hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape. ### Data-boundary discipline -Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own. +Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` plus the hooks bound from provide contributions and inject `hooks` compartments — every one synthesized by the renderer's single binding machinery; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own. ### Tree context and the renderer seam @@ -111,7 +111,7 @@ Render authority is enforceable rather than conventional: who renders what is a | Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks | | Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything | | `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn | -| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine | +| Business hand-made hooks / raw observables in component props | Every plugin becomes its own subscription machine; the inject `hooks` compartment carries the same facts through the one audited binding machinery | | Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation | | Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact | | `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) | diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 52edea30ac..90473861c1 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -45,7 +45,7 @@ ctx.slots.register({ | 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` | | 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | -| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) | +| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use` 选择器 hook 到达(`InjectFace`) | 凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。 @@ -80,11 +80,11 @@ store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会 ### inject:注册方的业务面,立足自己的 ctx -inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。 +inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值是普通数据与回调,至多外加保留键 `hooks` 格:一张裸 observable source(getSnapshot+subscribe)表,渲染器在业务面抵达组件前把每个 source 绑成 `use` 选择器 hook——即 provide 通道 hooks 格的注册方私有孪生,供太小众、不该进全局标准件的响应式事实(composer 的 notices/lexicon、settings 导航行)取用。组件永远收不到裸 source,业务代码因此仍零订阅机械。其余保持普通:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁手造 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。 ### 数据界线纪律 -hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 +hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 五席,加上 provide 贡献与 inject `hooks` 格绑出的 hook——全部出自渲染器同一台绑定机械;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 ### 树上语境与渲染器安装缝 @@ -111,7 +111,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替 | 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 | | 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 | | `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 | -| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 | +| 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械;inject `hooks` 格让同样的事实走那一台受审计的绑定机械 | | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 | | 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 | | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) | diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 3728641560..0fd9e71f01 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -11,10 +11,10 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`. 2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `..` (e.g. `'conversation.chat.toolview'`). 3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots` (children keys) & `PropsStore` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally. -4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) +4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription. 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). -7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. +7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. ## Export discipline (client plugin packages) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 789f86aeb6..62801ca0b8 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -135,13 +135,15 @@ export function apply(ctx: Context): void { 'conversation.input.model': { kind: 'single', scope: 'session' }, }, inject: (sessionId: SessionId): ComposerBarInjected => { + const shell = inputHub.shell(sessionId) return { - keyboard: inputHub.keyboard(sessionId), + keyboard: shell, stop: () => { scopedConversation(sessions, sessionId).cancel().catch(() => { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, + hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, }, InputBar) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 44f337d61c..7a99323826 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,11 +1,11 @@ /** Conversation slot declarations and their composed component props. */ import type { ReactNode, RefObject } from 'react' import type { - MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, + InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts' +import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -220,6 +220,13 @@ export interface ComposerBarInjected { keyboard: ComposerKeyboard /** Cancel the in-flight turn. */ stop: () => void + /** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */ + hooks: { + /** Latest surfaced notice (null after none; seq keys re-render of repeats). */ + notices: ObservableSnapshot + /** Hot plain-text reference lexicon for the decoration scan (decision 21). */ + lexicon: ObservableSnapshot> + } } /** @@ -231,11 +238,11 @@ export interface InputControlOwnerProps { locked: boolean } -/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */ +/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> - & ComposerBarInjected + & InjectFace /** * Composer chain currency: what ConversationRoot dispatches at its diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index c229abf18c..4454662cd6 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -5,7 +5,7 @@ * conversation wiring layer alone sees the full SessionInput. InputMachine * (machine.ts) is package-private and never exported. */ -import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, @@ -77,8 +77,6 @@ export interface InputNotice { * satisfies it structurally. */ export interface ComposerKeyboard { - /** Latest surfaced notice store (null after none). */ - readonly notices: SnapshotStore /** Live machine state for event-handler reads (render reads go through useInput). */ readonly snapshot: InputState /** Draft write with the DOM-observed edit shape (narrows occurrence math). */ @@ -99,8 +97,6 @@ export interface ComposerKeyboard { space(): boolean /** Dismiss the popupSelect shell (any interaction outside the box). */ dismissPopup(): void - /** Hot plain-text reference lexicon source for the decoration scan (decision 21; empty Map without a pipeline). */ - readonly lexicon: ObservableSnapshot> } /** One queued-message row projected from the session/queued frames (T9 supplies the store). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index aa224d1ec1..a844c18a72 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -1,11 +1,12 @@ /** The default composer body: the 'conversation.composer.bar' slot entry * (decision 20). Machine state arrives through the standard provide channel * (useInput + inputActions); the keyboard/DOM command face and stop arrive - * through this entry's own inject; layout-phase inputs (variant, placeholder, + * through this entry's own inject, whose hooks compartment binds + * useNotices/useLexicon; layout-phase inputs (variant, placeholder, * region-slot content) ride the owner props. Session facts * (running/removed/promptError) are self-selected via useSession. */ -import { useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -27,20 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [ ] export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, renderSlot, + useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) - const noticeStore = keyboard.notices - const notice = useSyncExternalStore( - (fn: () => void) => noticeStore.subscribe(fn), - () => noticeStore.getSnapshot(), - ) - const lexiconStore = keyboard.lexicon - const lexicon = useSyncExternalStore( - (fn: () => void) => lexiconStore.subscribe(fn), - () => lexiconStore.getSnapshot(), - ) + const notice = useNotices(s => s) + const lexicon = useLexicon(s => s) const promptError = useSession(s => s.promptError) const running = useSession(s => s.running) const disabled = useSession(s => s.removed) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index ab62af80bb..4999125c62 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -91,6 +91,8 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + useNotices: bindSnapshotSelector(shell.notices), + useLexicon: bindSnapshotSelector(shell.lexicon), stop, renderSlot, variant: over?.variant ?? 'composer', diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 284ef6c76a..f21f124b78 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + useNotices: bindSnapshotSelector(shell.notices), + useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), variant: 'composer', diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 414f3c15b4..826405f2be 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + useNotices: bindSnapshotSelector(shell.notices), + useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), variant: 'composer', diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b777c85ac3..27c635a1f0 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -118,6 +118,8 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + useNotices={bindSnapshotSelector(wiring.notices)} + useLexicon={bindSnapshotSelector(wiring.lexicon)} stop={stop} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 22946e799a..c3480e1d18 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -7,10 +7,10 @@ * aria-labelledby the title node; close: visually-hidden slot text). Modal * open state and the active section id are component-local viewing state. */ -import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { SettingsRootComponentProps } from './contract/slots.ts' +import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ @@ -20,7 +20,7 @@ function navIcon(id: string) { } type PanelProps = { - rows: ReturnType + rows: readonly SettingsSectionRow[] renderSlot: SettingsRootComponentProps['renderSlot'] onClose: () => void } @@ -92,18 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) { * @returns the settings shell element tree. */ export function SettingsRoot(props: SettingsRootComponentProps) { - const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props + const { wide, useSections, renderSlot } = props const [open, setOpen] = useState(false) const close = useCallback(() => { setOpen(false) }, []) // The ledger tick keeps the nav rows fresh: registrants re-register with // freshly localized text on locale change, and the trigger/header/close // seats re-render through their own outlets' subscriptions. - // uSES over the ledger version: same-version notifications dedupe to no - // render, and a registration landing between render and effect - // subscription cannot be missed. - useSyncExternalStore(subscribeSections, sectionsVersion) - const rows = sections() + const rows = useSections(s => s) return ( <> diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 1a263108bc..c20a041858 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -7,7 +7,7 @@ * setting never means editing the shell; copy that belongs to no single * feature (chrome, the General section) is owned by ui-settings-general. */ -import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) // into every program that sees this contract. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps { children?: never } +/** One nav row projected from a settings.section registration's options. */ +export interface SettingsSectionRow { + id: string + order: number + label: string +} + /** * Registrant-private injected share of the settings shell (assembled in - * apply): ledger projections only — the shell reads no locale state. + * apply): the ledger's nav-row projection as a hooks-compartment source — + * the shell reads no locale state and subscribes through the bound hook. */ export type SettingsRootInjected = { - /** Read the settings.section ledger version (nav invalidation). */ - sectionsVersion: () => number - /** Subscribe to settings.section ledger changes. */ - subscribeSections: (listener: () => void) => () => void - /** Project the settings.section ledger into nav rows (id/order/label). */ - sections: () => readonly { id: string; order: number; label: string }[] + hooks: { + /** settings.section ledger projected into ordered nav rows. */ + sections: HostObservable + } } /** * Full component props of the settings shell root: the sidebar owner share - * (wide/rail state) plus the declared render shares and the injected face. - * No store is registered — modal open state and active section id are - * component-local viewing state. + * (wide/rail state) plus the declared render shares and the injected face + * (hooks compartment bound to useSections). No store is registered — modal + * open state and active section id are component-local viewing state. */ export type SettingsRootComponentProps = PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'> - & SettingsRootInjected + & InjectFace diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 7cb3dfd6d4..f858be9c37 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -10,12 +10,12 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' -import type { SettingsRootInjected } from './contract/slots.ts' +import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts' import { SettingsRoot } from './SettingsRoot.tsx' export type { SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected, - SettingsSectionOwnerProps, SettingsTriggerOwnerProps, + SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps, } from './contract/slots.ts' /** @@ -32,17 +32,31 @@ export const inject = ['slots'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + // Ledger → nav-row projection as an observable source (uSES contract: + // getSnapshot returns the cached rows until the ledger version moves). + let rowsVersion = -1 + let rows: readonly SettingsSectionRow[] = [] const injected = (): SettingsRootInjected => ({ - sectionsVersion: () => ctx.slots.getVersion('settings.section'), - subscribeSections: listener => ctx.slots.subscribe('settings.section', listener), - sections: () => ctx.slots.entries('settings.section') - .map(e => ({ - /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ - id: e.options.id ?? '', - order: e.options.order ?? 0, - label: e.options.label ?? '', - })) - .sort((a, b) => a.order - b.order), + hooks: { + sections: { + getSnapshot: () => { + const version = ctx.slots.getVersion('settings.section') + if (version !== rowsVersion) { + rowsVersion = version + rows = ctx.slots.entries('settings.section') + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + label: e.options.label ?? '', + })) + .sort((a, b) => a.order - b.order) + } + return rows + }, + subscribe: listener => ctx.slots.subscribe('settings.section', listener), + }, + }, }) ctx.effect(() => { const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index d7fdfbd546..caec65f3f5 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -60,22 +60,25 @@ describe('ui-settings apply', () => { const b = await bench() declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(b.slots) + const { sections } = injectedOf(b.slots).hooks // The shell ships no sections of its own — registrants fill the ledger. - expect(injected.sections()).toEqual([]) + expect(sections.getSnapshot()).toEqual([]) b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) // No order and no label: both projection defaults apply. b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) - expect(injected.sections()).toEqual([ + const rows = sections.getSnapshot() + expect(rows).toEqual([ { id: 'a', order: 0, label: '' }, { id: 'z', order: 20, label: 'Z' }, ]) - expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) + // Snapshot identity is stable until the ledger moves (uSES contract). + expect(sections.getSnapshot()).toBe(rows) const listener = vi.fn() - const off = injected.subscribeSections(listener) + const off = sections.subscribe(listener) b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) await Promise.resolve() expect(listener).toHaveBeenCalled() + expect(sections.getSnapshot()).not.toBe(rows) off() }) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index 9584d500e5..dd340dc2ea 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' @@ -22,9 +23,9 @@ function mount({ { id: 'models', order: 10, label: 'Models' }, ], }: { wide?: boolean; rows?: Row[] } = {}) { - // Mutable row store standing in for the ledger; bump() plays a change. + // Mutable row source standing in for the bound useSections hook; bump() + // plays a ledger change through the same observable contract. let current = rows - let version = 0 const listeners = new Set<() => void>() const renderSlot = vi.fn( ((key: string, _owner: unknown, opts?: { only?: string }) => { @@ -38,19 +39,21 @@ function mount({ useSessions: unusedHook, useWorkspaces: unusedHook, wide, - sectionsVersion: () => version, - subscribeSections: (listener) => { - listeners.add(listener) - return () => { listeners.delete(listener) } + useSections: (select) => { + const [, force] = useState(0) + useEffect(() => { + const listener = () => { force(n => n + 1) } + listeners.add(listener) + return () => { listeners.delete(listener) } + }, []) + return select(current) }, - sections: () => current, renderSlot, } const view = render() const bump = (next: Row[]) => { act(() => { current = next - version += 1 for (const fn of [...listeners]) fn() }) } diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 1f30f7027b..7b980571ef 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -14,6 +14,7 @@ * consumer merges keys in and the intersection is what keeps them string-typed. * The rule fires on the empty-map view, not on real redundancy. */ import type { ReactNode } from 'react' +import type { HostObservable } from './renderer.ts' import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts' export * from './store.ts' @@ -214,11 +215,40 @@ export type PropsRenderSlots = { */ 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. + */ +export type HooksSources = Record> + +/** + * Selector-hook share synthesized from a hooks compartment: each source + * `name` becomes a `use` selector hook over its snapshot type. + */ +export type PropsHooks = { + [N in keyof HS & string as `use${Capitalize}`]: + SnapshotSelectorHook ? T : never> +} + +/** + * The component-side view of an inject face: the reserved `hooks` + * compartment (when declared) arrives as bound `use` selector hooks; + * every other member passes through verbatim. + */ +export type InjectFace = + I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : I + /** * The four-share component props intersection: runtime share (SlotMap) + * child-render share (children declaration) + store share (declared handle) + - * the registrant's injected business face. Each share derives from its single - * source of truth; components reference this composition, never re-type it. + * the registrant's injected business face (its hooks compartment bound, see + * {@link InjectFace}). Each share derives from its single source of truth; + * components reference this composition, never re-type it. */ export type ComposedProps< K extends keyof SlotMap & string, @@ -226,7 +256,7 @@ export type ComposedProps< H, I extends object, M = never, -> = PropsRuntime & PropsRenderSlots & PropsStore & I & MatchedShare +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare /** * Inject factory parameter list, derived from the registration's declaration: diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index 3ef01d7390..5cea31cd6c 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -5,8 +5,8 @@ import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, - type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo, - type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, + type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo, + type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, } from '@deepseek-ai/dsh-client-ui-slots' import { HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook, @@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined const args: unknown[] = [] if (info !== undefined) args.push(info.sessionId) if (actions !== undefined) args.push(actions) - return (inject as (...args: unknown[]) => InjectedProps)(...args) + return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args)) +} + +/** + * 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. + */ +function bindInjectHooks(face: InjectedProps): InjectedProps { + const sources = face['hooks'] + if (sources === undefined) return face + const { hooks: _hooks, ...rest } = face + const bound: InjectedProps = rest + for (const [name, source] of Object.entries(sources as Record>)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + bound[hookName] = observableHook(source) + } + return bound } function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps { diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 36b99a6ef8..971a6ad060 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -748,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', () expect(inject).toHaveBeenCalledWith() }) + it('binds the inject hooks compartment into use selector hooks (sources never reach the component)', () => { + const h = makeHost() + h.declare('k.single', SINGLE_ROOT) + const badge = observable('cold') + const seen: Record[] = [] + h.add('k.single', { + component: (props: { useBadge?: (sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => { + seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) }) + return null + }, + inject: () => ({ plain: 'kept', hooks: { badge } }), + }) + mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {})) + // The raw compartment is consumed by the binding; the plain member passes through. + expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' }) + act(() => { badge.set('hot') }) + expect(seen.at(-1)!['read']).toBe('hot') + }) + it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) From 305f185ede0a40e064a2f618007323d1b6cde318 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:13:32 +0000 Subject: [PATCH 27/33] chore(deps): bump actions/upload-artifact from 6 to 7 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build-exe-for-python-sdk.yml | 4 ++-- .github/workflows/ci.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index a112267f4d..5965707630 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -106,7 +106,7 @@ jobs: --package sdk --output-dir dist-python - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl @@ -237,7 +237,7 @@ jobs: /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default ' - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: ${{ steps.runtime.outputs.wheel }} path: dist-python/${{ steps.runtime.outputs.wheel }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c3697f3b8..577bd92b42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz" apps/*/lib packages/*/*/lib vendor/*/lib - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: node-24-built-tree path: ${{ runner.temp }}/node-24-built-tree.tar.gz From 2b74db670efbbbe7e84b763e263ca4f3b6a52c4e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:06:08 +0800 Subject: [PATCH 28/33] refactor(client): rename the provide reprojection to updateCurrentProvideInfo and privatize the id resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provideInfo(id)/maybeProvideInfo(id) lost their last external caller when the renderer host switched to the currentProvideInfo observable; both become private (tests assert through the public projection). The reprojection method's name now says what it does — re-derive and publish on change — and matches the field family it maintains. --- packages/client/runtime/README.md | 2 +- .../runtime/src/client/sessions/service.ts | 23 ++++++------- .../runtime/tests/sessions-service.spec.ts | 32 +++++++++++-------- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 81261945cb..16c1124ec8 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac ## Known Limitations and Deferred Work - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. -- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. +- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b5ce1905eb..759c320bde 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -212,7 +212,7 @@ export class SessionsService { // The current-provide projection follows the same current writes. this.list.subscribe(() => { this.followCurrent() - this.projectCurrentProvide() + this.updateCurrentProvideInfo() }) // The runtime's own contribution comes first: useSession rides the same // provide channel every plugin uses (no renderer special case). @@ -261,16 +261,17 @@ export class SessionsService { for (const record of this.scopes.values()) { record.provideInfo = this.materializeProvideInfo(record.binding) } - this.projectCurrentProvide() + this.updateCurrentProvideInfo() } /** - * Publish the current selection's provide bundle when it changed. Bundles - * are identity-stable per (scope, roster) materialization, so an identity - * compare is exact; synchronous notify — both call sites (list.subscribe, - * provide()) already sit behind their own batching or registration edges. + * Re-derive the current selection's provide bundle and publish it when it + * changed. Bundles are identity-stable per (scope, roster) + * materialization, so an identity compare is exact; synchronous notify — + * both call sites (list.subscribe, provide()) already sit behind their own + * batching or registration edges. */ - private projectCurrentProvide(): void { + private updateCurrentProvideInfo(): void { const next = this.maybeProvideInfo(this.list.getSnapshot().current) if (next === this.currentProvideInfoSnapshot) return this.currentProvideInfoSnapshot = next @@ -446,20 +447,16 @@ export class SessionsService { * {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe: * no staging, no window side effects (StrictMode double-invokes and * concurrent discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. */ - provideInfo(id: string): SessionProvideInfo | undefined { + private provideInfo(id: string): SessionProvideInfo | undefined { return this.resolve(id as SessionId)?.provideInfo } /** * Resolve the current-session-optional standard kit. Unknown or absent ids * return the static no-session projection rather than removing hook props. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { + private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index b97dac3d78..45539d3b99 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -79,7 +79,8 @@ describe('scope tree', () => { expect(scopeOf(scoped as Context)).toBe('s1') expect(scopeOf(b.ctx)).toBeUndefined() const binding = b.svc.binding(sid('s1')) - expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session']) + b.svc.open(sid('s1')) + expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session']) expect(b.svc.binding(sid('s1'))).toBe(binding) expect(binding?.ctx).toBe(scoped) }) @@ -183,16 +184,17 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s }) describe('cell (render-layer session kit)', () => { - it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => { + it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - const info = b.svc.provideInfo('s1') - expect(info).toBeDefined() - expect(info?.sessionId).toBe('s1') + b.svc.open(sid('s1')) + const info = b.svc.currentProvideInfo.getSnapshot() + expect(info.sessionId).toBe('s1') // The bundle carries bare observables; hook binding happens in React. - expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) - expect(b.svc.provideInfo('s1')).toBe(info) - expect(b.svc.provideInfo('ghost')).toBeUndefined() + expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) + // Re-staging the same id republishes nothing: identity holds. + b.svc.open(sid('s1')) + expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info) }) it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => { @@ -204,10 +206,14 @@ describe('cell (render-layer session kit)', () => { const notified = vi.fn() b.svc.currentProvideInfo.subscribe(notified) b.svc.open(sid('s1')) - expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s1')) + const s1Bundle = b.svc.currentProvideInfo.getSnapshot() + expect(s1Bundle.sessionId).toBe('s1') + expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) expect(notified).toHaveBeenCalledTimes(1) b.svc.open(sid('s2')) - expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s2')) + const s2Bundle = b.svc.currentProvideInfo.getSnapshot() + expect(s2Bundle.sessionId).toBe('s2') + expect(s2Bundle).not.toBe(s1Bundle) expect(notified).toHaveBeenCalledTimes(2) b.svc.clear() await Promise.resolve() // clearSelection projects through the manager notifier @@ -249,12 +255,11 @@ describe('cell (render-layer session kit)', () => { expect(notified).not.toHaveBeenCalled() }) - it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => { + it('binding() is pure resolution: no staging, no deferred sweep', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) b.svc.open(sid('s1')) // staged - b.svc.provideInfo('s2') // resolution only — must NOT move the stage - b.svc.binding(sid('s2')) + b.svc.binding(sid('s2')) // resolution only — must NOT move the stage await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives expect(b.svc.scope(sid('s1'))).toBeDefined() }) @@ -265,7 +270,6 @@ describe('cell (render-layer session kit)', () => { const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') // Resolution is addressing, not staging: no window pull. b.svc.scope(sid('s1')) - b.svc.provideInfo('s1') b.svc.binding(sid('s1')) expect(historyCalls()).toHaveLength(0) b.svc.open(sid('s1')) From f331f248d88762ead77e42dae721877f123506f8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:16:14 +0800 Subject: [PATCH 29/33] fix: static --- packages/client/runtime/README.i18n.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3fa9c934f3..4629e67d59 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: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499 +README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200 From d833be412afa0f091d9f206140fc095847681198 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:33:37 +0800 Subject: [PATCH 30/33] fix(client): contain notification-callback failures and document the source lifecycle Review follow-ups: the three new notify loops (currentProvideInfo subscribers, ui-skill lexicon listeners, late-registration controller setup) now contain per-callback failures so one faulty consumer cannot starve the rest, abort the list projection pass, or poison the source roster with no disposer; controller lexicon polling drops a throwing source with a console record like the candidate path. The ui-slash README (both languages) now states the late-registration warm and the subscribeLexicon contract, and the scenario suite drives a typed /name token gaining its decoration when the roll settles with no further input. --- .../runtime/src/client/sessions/service.ts | 11 ++++++- .../tests/input-scenarios.spec.tsx | 29 +++++++++++++++++++ packages/client/ui-skill/src/client/index.ts | 11 ++++++- packages/client/ui-slash/README.i18n.yaml | 6 ++-- packages/client/ui-slash/README.md | 2 +- packages/client/ui-slash/README.zh.md | 2 +- .../client/ui-slash/src/client/controller.ts | 11 ++++++- .../client/ui-slash/src/client/service.ts | 11 ++++++- 8 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 759c320bde..9754c87345 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -275,7 +275,16 @@ export class SessionsService { const next = this.maybeProvideInfo(this.list.getSnapshot().current) if (next === this.currentProvideInfoSnapshot) return this.currentProvideInfoSnapshot = next - for (const fn of [...this.currentProvideInfoListeners]) fn() + for (const fn of [...this.currentProvideInfoListeners]) { + try { + fn() + } catch (error) { + // Contain subscriber failures: this notify runs inside the list + // notification, where a throwing render-side subscriber would starve + // later listeners and abort the projection pass that scheduled it. + console.error('sessions.currentProvideInfo subscriber failed:', error) + } + } } /** Build the static no-session kit and reject duplicate declared names. */ diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 826405f2be..807650169b 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -236,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => { }) }) +describe('scenario: reference decoration lights up when the lexicon settles', () => { + it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => { + let roll: readonly string[] | undefined + let notify: (() => void) | undefined + const b = await scopedBench((slash) => { + slash.registerSource({ + trigger: '/', name: 'skill', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + lexicon: () => roll, + subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => { + notify = listener + return () => { notify = undefined } + }, + } as never) + }) + // Typed before the catalog settled: a plain token, no decoration. + b.type('/deploy now') + expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull() + // The catalog settles (ui-skill's settle path fires the same notification). + act(() => { + roll = ['deploy'] + notify?.() + }) + const mark = b.view.container.querySelector('[data-decoration="text-ref"]') + expect(mark?.textContent).toBe('/deploy') + }) +}) + describe('scenario I: unknown /xyz + enter', () => { it('adjudication misses in one hop and the whole line rides the default sink', async () => { const b = await bench() diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 7226f45163..d34c6ba96d 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -48,7 +48,16 @@ export function apply(ctx: ClientContext): void { const lexiconListeners = new Map void>>() const notifyLexicon = (sessionId: SessionId): void => { - for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) listener() + for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) { + try { + listener() + } catch (error) { + // Contain listener failures: settlement notifies from an ignored + // promise chain (a throw would surface as an unhandled rejection) + // and one faulty consumer must not starve the others. + console.error('[ui-skill] lexicon listener failed:', error) + } + } } const fetchCatalog = (sessionId: SessionId): Promise => { diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index c09d7f4c28..1053e205b0 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a -README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018 +# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md +README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d +README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index d2978695d7..4e363c2682 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 6aeb078a92..76d39673cb 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index 9e95dbdd41..ab0b26da54 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -290,7 +290,16 @@ export class SlashController { const rolls = new Map() for (const src of this.deps.roster.all()) { if (src.lexicon === undefined) continue - const names = src.lexicon(projection) + let names: readonly string[] | undefined + try { + names = src.lexicon(projection) + } catch (error) { + // A faulty source drops silently with a console record (the + // candidate-fetch failure policy); the refresh runs inside + // notification callbacks, where a throw would starve other consumers. + console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error) + continue + } if (names === undefined) continue const prev = rolls.get(src.trigger) rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names]) diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index 0ca3b91c2a..c47d44c3d4 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -50,7 +50,16 @@ export class SlashService extends Service implements SlashServiceContract { throw new Error(`slash source "${src.trigger}${src.name}" is already registered`) } live.sources.push(src) - for (const controller of live.controllers.values()) controller.sourceAdded(src) + for (const controller of live.controllers.values()) { + try { + controller.sourceAdded(src) + } catch (error) { + // Contain faulty source callbacks (warm/subscribeLexicon): the + // registration must stand with a usable disposer and the remaining + // controllers must still be notified. + console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error) + } + } return () => { const at = live.sources.indexOf(src) if (at < 0) return From 2665e55e5d3437ce5013fe1e49a6698bd63c6eb3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:36:16 +0800 Subject: [PATCH 31/33] docs(runtime): align the zh README resolution sentence with the privatized resolvers --- packages/client/runtime/README.i18n.yaml | 2 +- packages/client/runtime/README.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 4629e67d59..32d4fdf417 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -3,4 +3,4 @@ # 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: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc -README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200 +README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index cbbf6eded4..a3d2a2dfdd 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 已知限制与暂缓事项 - **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。 -- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 +- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 - **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。 From 059ba4e0d1827b15f979e7b42c98e0d34b2c8820 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:48:46 +0800 Subject: [PATCH 32/33] docs(client): add the reactive-read and contract-currency discipline --- packages/client/AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 0fd9e71f01..11be39a93d 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -16,6 +16,17 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). 7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. +## Reactive read and contract-currency discipline + +The three stale-UI bugs this section descends from shared one root: mutable state read during render without a subscription. The rules: + +1. **Everything a render reads that can change outside React arrives through a subscription**: a framework hook (rule 4 above), never a getter call, a `.getSnapshot()` in render, or a mirror copied into `useState`/a second store. Event handlers may read live snapshots (`keyboard.snapshot`); render may not. +2. **Business components contain no subscription machinery**: no `useSyncExternalStore`, no manual `useState`+`useEffect` subscribe pattern (it has a render-to-effect gap that drops notifications). A registrant-private reactive fact goes through the inject `hooks` compartment; a cross-entry fact goes through a store; a per-session fact goes through `sessions.provide`. +3. **Data-access ladder** — resolve needs in this order, and escalate rather than improvise: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration, never a hand-rolled subscription. +4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is NOT a currency: do not add new ReactNode-valued owner props or inject members (existing ones — composer `accessory`/`overlay`/`leftItems`/`rightItems` — are legacy under progressive removal; route new render content through a slot instead). +5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source; a fresh source per render re-subscribes uSES), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves — a fresh object per call is an infinite re-render). +6. **Whoever rebuilds a published value republishes it through the same source in the same step.** Rebuild-without-notify is exactly the stale-roster bug; registration paths that can run after consumers exist must notify the live consumers (the slash late-source warm is the template). + ## Export discipline (client plugin packages) The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments): From 095e3944ae51aff390dfd62a6139e55f0c4bc656 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:52:04 +0800 Subject: [PATCH 33/33] docs(client): state the reactive-read rules positively --- packages/client/AGENTS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 11be39a93d..a7d80b3232 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -18,14 +18,14 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- ## Reactive read and contract-currency discipline -The three stale-UI bugs this section descends from shared one root: mutable state read during render without a subscription. The rules: +How live data reaches render code, and what may cross a business boundary: -1. **Everything a render reads that can change outside React arrives through a subscription**: a framework hook (rule 4 above), never a getter call, a `.getSnapshot()` in render, or a mirror copied into `useState`/a second store. Event handlers may read live snapshots (`keyboard.snapshot`); render may not. -2. **Business components contain no subscription machinery**: no `useSyncExternalStore`, no manual `useState`+`useEffect` subscribe pattern (it has a render-to-effect gap that drops notifications). A registrant-private reactive fact goes through the inject `hooks` compartment; a cross-entry fact goes through a store; a per-session fact goes through `sessions.provide`. -3. **Data-access ladder** — resolve needs in this order, and escalate rather than improvise: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration, never a hand-rolled subscription. -4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is NOT a currency: do not add new ReactNode-valued owner props or inject members (existing ones — composer `accessory`/`overlay`/`leftItems`/`rightItems` — are legacy under progressive removal; route new render content through a slot instead). -5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source; a fresh source per render re-subscribes uSES), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves — a fresh object per call is an infinite re-render). -6. **Whoever rebuilds a published value republishes it through the same source in the same step.** Rebuild-without-notify is exactly the stale-roster bug; registration paths that can run after consumers exist must notify the live consumers (the slash late-source warm is the template). +1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes. +2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`. +3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration. +4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively). +5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). +6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. ## Export discipline (client plugin packages)