From c58097a8267ef299e03494f26801d76c1ba73ca9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 31 Aug 2026 14:45:31 +0800 Subject: [PATCH 1/5] feat(session-persistence-jsonl): cross-process write-ownership lease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write handles now hold a durable lease (session.lock.json beside the log): a random owner token, diagnostic pid, and an expiry. Acquisition wins by exclusive create; a second process's create or write open rejects while the record is unrenewed for less than leaseTtlMs (default 5 min), and takes over after that — a crashed holder is waited out, never reclaimed by pid. The holder renews every leaseRenewIntervalMs (default 4 min); a renewal that finds a foreign, vanished, or expired record — or fails outright — marks the lease lost permanently, so every later append/flush rejects with SessionOwnershipLostError while reads continue. Close releases the record; read handles never touch it. Takeover of an expired record is eventually exclusive: a replaced holder stops within one renewal interval. Refs #3245 --- ...ross-process-session-write-lease.i18n.yaml | 6 + ...08-31-cross-process-session-write-lease.md | 29 ++ ...31-cross-process-session-write-lease.zh.md | 29 ++ THIRD_PARTY_NOTICES.md | 2 + .../session-format-guard.expected.e2e.ts | 4 +- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 2 +- docs/subsystems/persistence.zh.md | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 21 +- .../webworker-runtime/src/module-proxies.ts | 1 + .../webworker-runtime/src/node/builtins.ts | 2 + .../src/node/external_packages/fs-ext.ts | 42 ++ .../external_packages/replaced-externals.ts | 1 + .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/package.json | 4 +- .../session-persistence-jsonl/src/index.ts | 53 ++- .../session-persistence-jsonl/src/lease.ts | 145 ++++++ .../session-persistence-jsonl/src/storage.ts | 48 +- .../session-persistence-jsonl/src/win32.ts | 53 +++ .../tests/fixtures/lease-holder.mjs | 28 ++ .../tests/jsonl.spec.ts | 11 +- .../tests/lease.spec.ts | 437 ++++++++++++++++++ .../tests/lease.two-process.e2e.ts | 69 +++ .../tests/win32.spec.ts | 78 ++++ .../tests/live-write-contract.ts | 4 + .../subagent/tests/continuation.spec.ts | 7 +- pnpm-lock.yaml | 26 ++ pnpm-workspace.yaml | 3 + scripts/gen-third-party-notices.ts | 2 + vitest.config.ts | 10 +- 34 files changed, 1106 insertions(+), 29 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md create mode 100644 .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/fs-ext.ts create mode 100644 packages/session/session-persistence-jsonl/src/lease.ts create mode 100644 packages/session/session-persistence-jsonl/tests/fixtures/lease-holder.mjs create mode 100644 packages/session/session-persistence-jsonl/tests/lease.spec.ts create mode 100644 packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml new file mode 100644 index 0000000000..908d48ae05 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.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/feature/2026-08-31-cross-process-session-write-lease.md +2026-08-31-cross-process-session-write-lease.md: 174c5152ea62e01e30ade9a68b6786638acb8ada +2026-08-31-cross-process-session-write-lease.zh.md: e4246f12f7ed8d8b304ca7f7514117f03f32267b diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md new file mode 100644 index 0000000000..174c5152ea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md @@ -0,0 +1,29 @@ +# Agent Note: cross-process session write lease + +Status: implemented + +English | [中文](2026-08-31-cross-process-session-write-lease.zh.md) + +## Problem + +The JSONL backend's write-handle claim excluded a second writer only inside one backend instance. Two processes — two CLI sessions, or a host beside an SDK runtime — could write-open the same session and interleave appends into one log file, tearing compressed frames and seq contiguity. The seam needed durable cross-process write ownership whose arbiter lives outside every writer process, because no writer outlives every failure mode. + +## Decision + +`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the pinned native dependency `fs-ext`, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs fs-ext to immediate success: it is single-process, so the in-process write claim already excludes every writer. + +## Alternatives considered + +**TTL record with renewal and claim-by-rename (implemented first, replaced in review)** — a JSON record beside the log carrying an owner token and expiry, renewed on an interval, taken over by atomic rename after expiry. It survives every filesystem but is a distributed algorithm in miniature: renewal timers, loss detection, takeover claiming with re-judgment and give-back — and its residual multi-actor races still allowed bounded dual-writer overlap (one renewal interval). Kernel arbitration deletes the whole family plus the machinery, at the cost of a native build dependency and the wedged-holder semantics above. + +**`proper-lockfile`** — the npm ecosystem's staleness-plus-touch implementation of the same TTL model. It retains the delete-then-recreate takeover race, detects compromise by mtime and inode (weaker than an owner token), and has had no release since 2021. + +**fs-ext's own Windows face (`LockFileEx` byte-range locks)** — rejected after CI proof: Windows byte-range locks are mandatory, so any reader touching the locked file hard-fails (ripgrep died with os error 33 walking a session directory). + +**Windows exclusive-open sharing mode (`CreateFileW` denying `FILE_SHARE_WRITE`)** — leaves readers untouched but pins the lock file's name and directory while held: CI showed dozens of suites failing their temp-root cleanup with EBUSY because a still-open handle blocks recursive removal, and users deleting a session directory would hit the same wall. The named semaphore keeps kernel arbitration with zero filesystem footprint. + +**Hand-rolled ffi for POSIX too (`flock(2)` via koffi)** — avoids the node-gyp install-time build, but means owning both platform lock implementations plus their error mapping; `fs-ext` ships the POSIX code maintained and pinned, and the Windows side reuses the koffi bindings `win32.ts` already owns. + +## Consequences + +Cross-process exclusion costs a node-gyp-compiled native dependency (`fs-ext`, allow-listed in `pnpm-workspace.yaml` `allowBuilds`), one lock file per materialized session that release deliberately leaves in place, and the wedged-holder rule: a stuck process blocks that session's writers until it exits. It buys immediate crash recovery (no waiting period), no renewal traffic, and the removal of every takeover race the TTL design managed rather than prevented. Advisory `flock` is unreliable on some network filesystems (NFSv3); a root on such a mount degrades toward in-process-only exclusion. Deleting a live session's lock file forfeits exclusion on POSIX by design — the harness never does so; the agent-loop resume test uses it deliberately to simulate a wedged first lifecycle, and skips on Windows, where the lock is a kernel object no file operation can forfeit. diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md new file mode 100644 index 0000000000..e4246f12f7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 跨进程会话写租约 + +Status: implemented + +[English](2026-08-31-cross-process-session-write-lease.md) | 中文 + +## Problem + +JSONL 后端的写句柄认领只在单个后端实例内部排除第二个写入方。两个进程——两个 CLI 会话,或宿主与 SDK 运行时并存——可以对同一会话执行写打开,把追加交错写进同一个日志文件,撕坏压缩帧与 seq 连续性。该 seam 需要一份仲裁者位于所有写入进程之外的持久跨进程写所有权,因为没有任何写入方能活过所有故障模式。 + +## Decision + +`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由固定版本的原生依赖 `fs-ext` 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 fs-ext 存根为立即成功:它是单进程部署,进程内写认领已排除所有写入方。 + +## Alternatives considered + +**TTL 记录加续约与 rename 认领(最初实现,review 中被替换)** —— 日志旁的 JSON 记录携带 owner 令牌与过期时间,按间隔续约,过期后以原子 rename 接管。它在所有文件系统上都能活,但本质是一个微缩的分布式算法:续约定时器、丢失检测、带复核与归还的接管认领——而其残余的多方竞态仍允许有界的双写重叠(一个续约间隔)。内核仲裁删除了整族竞态及其全部机制,代价是一个原生构建依赖和上述卡死持有者语义。 + +**`proper-lockfile`** —— npm 生态对同一 TTL 模型的"过期判定加 touch"实现。它保留"先删后建"的接管竞态,用 mtime 加 inode 检测失主(弱于 owner 令牌),且自 2021 年起再无发布。 + +**fs-ext 自带的 Windows 实现(`LockFileEx` 字节区间锁)** —— 被 CI 实证否决:Windows 的字节区间锁是强制锁,任何读到被锁文件的进程都会硬失败(ripgrep 遍历会话目录时以 os error 33 崩掉)。 + +**Windows 共享模式独占打开(`CreateFileW` 拒绝 `FILE_SHARE_WRITE`)** —— 读者不受影响,但持有期间钉住锁文件的名字与目录:CI 显示数十个套件的临时根清理因仍打开的句柄阻塞递归删除而报 EBUSY,用户删除会话目录也会撞上同一堵墙。命名信号量保住内核仲裁,且文件系统足迹为零。 + +**POSIX 也手写 ffi(经 koffi 调 `flock(2)`)** —— 免去 node-gyp 安装期编译,但意味着自有两个平台的锁实现及其错误映射;`fs-ext` 交付了有维护、可固定版本的 POSIX 侧,Windows 侧复用 `win32.ts` 已自有的 koffi 绑定。 + +## Consequences + +跨进程排他的代价是一个 node-gyp 编译的原生依赖(`fs-ext`,已在 `pnpm-workspace.yaml` 的 `allowBuilds` 列入允许)、每个物化会话一个由释放刻意留下的锁文件,以及卡死持有者规则:卡住的进程阻塞该会话的写入方直到其退出。它换来的是即时崩溃恢复(无等待期)、零续约流量,以及删除了 TTL 设计只能"管理"而非"消除"的全部接管竞态。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠;位于此类挂载上的根目录会退化为仅进程内排他。POSIX 上删除活跃会话的锁文件按设计即放弃排他——harness 自身从不这样做;agent-loop 的 resume 测试刻意用它模拟卡死的第一个生命周期,并在 Windows 上跳过:那里的锁是任何文件操作都无法放弃的内核对象。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 694964b0ce..1be29ddbaf 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -70,6 +70,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | +| [`fs-ext`](https://github.com/baudehlo/node-fs-ext) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | @@ -148,6 +149,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/fs-ext`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts index 8f97b62ced..54b4c734e7 100644 --- a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts @@ -106,8 +106,10 @@ describe('session format guard through the assembled app', () => { version: SESSION_FORMAT_VERSION, }) expect(current.trimEnd().split('\n').length).toBeGreaterThan(closedTurn().length + 1) + // `session.lock` is the write handle's kernel lock file, published + // with the first materializing write and kept across release. expect((await readdir(dirname(sourcePath))).sort()) - .toEqual(['session.jsonl', generationLogFilename(SESSION_FORMAT_VERSION, 'none')]) + .toEqual(['session.jsonl', 'session.lock', generationLogFilename(SESSION_FORMAT_VERSION, 'none')]) }, }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index f9c1c71c3b..5ccd548a78 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: cd84522a925025686607b3b8f252d0474e357fa7 +config-catalog.md: 39f308781faf8f51f4a54c41fe920c7e28d39148 config-catalog.zh.md: d23b8471e9085bc1128f1dc32937a9070802506f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cd84522a92..39f308781f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1842,7 +1842,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:85`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:86`](../packages/session/session-persistence-jsonl/src/index.ts) diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 5e526daf66..2d933b93e3 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 579a61400a8a8b7965c58e50ab33ba7b1c385424 -persistence.zh.md: 00823763d9c1d034855df672461f932254a205c7 +persistence.md: 7cce80f43591610c1e0667d480971dbd8f3cd4f7 +persistence.zh.md: 73169ec2f3a58f06a7355922b0e0f7e4e82944c7 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 579a61400a..7cce80f435 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -8,7 +8,7 @@ The seam is a [capability seam](../../.agents/notes/implemented/architecture/202 ## `SessionHandle` — one open channel onto a stored session -Every log read and write flows through a handle, never through id-addressed service methods: the handle is the single door a future cross-process write lease will guard. One handle type serves both accesses — a mutation on a `read` handle is a runtime `SessionReadOnlyError` rather than a typed split — and in-process single-writer ownership makes a second `open(id, 'write')` reject with `SessionAlreadyOwnedError` while an owner is active. +Every log read and write flows through a handle, never through id-addressed service methods: the handle is the single door the cross-process write lease guards. One handle type serves both accesses — a mutation on a `read` handle is a runtime `SessionReadOnlyError` rather than a typed split — and in-process single-writer ownership makes a second `open(id, 'write')` reject with `SessionAlreadyOwnedError` while an owner is active. ```ts type-equiv /** diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 00823763d9..73169ec2f3 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -8,7 +8,7 @@ ## `SessionHandle`——通向已存储会话的一条打开通道 -每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是未来跨进程写租约将要把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。 +每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是跨进程写租约把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。 ```ts type-equiv /** diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 48ee170fec..1da7449e1b 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -42,6 +42,15 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter, compre return ctx } +/** Remove every `session.lock` under the root: the POSIX forfeit-by-unlink escape hatch, without importing backend internals. */ +async function removeSessionLocks(dir: string): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) await removeSessionLocks(path) + else if (entry.name === 'session.lock') await rm(path, { force: true }) + } +} + /** Seed one stored session through the persistence seam (header minted by the store). */ async function seedStoredSession(ctx: Context, sessionId: SessionId, events: readonly SessionEvent[]): Promise { const detached = ctx.sessions.prepare(sessionId) @@ -930,7 +939,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.fiber.dispose() }) - it('a pending idle inject() survives persist + resume without a synthetic turn', async () => { + // The crash simulation removes the wedged lifecycle's lock file, which only + // POSIX's orphan-inode forfeit honors; Windows pins the name until the + // process exits, and cross-process crash release is pinned by the jsonl + // two-process e2e. + it.skipIf(process.platform === 'win32')('a pending idle inject() survives persist + resume without a synthetic turn', async () => { const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent @@ -939,6 +952,12 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background job 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } })) await a1.whenIdle() await ctx1.sessions.flush(a1.session) + // Simulate a wedged first lifecycle: a graceful dispose would durably + // discard the pending inject, and the still-open kernel write lock would + // otherwise exclude the second lifecycle. Removing the lock file orphans + // the held inode so the resumer locks a fresh one (the documented + // forfeit-by-unlink escape hatch). + await removeSessionLocks(root) // Lifecycle 2: resume; the injected context is still pending and becomes // model-visible when the next turn admits it. diff --git a/packages/experimental/webworker-runtime/src/module-proxies.ts b/packages/experimental/webworker-runtime/src/module-proxies.ts index 3bb59ef366..8fef2b62b6 100644 --- a/packages/experimental/webworker-runtime/src/module-proxies.ts +++ b/packages/experimental/webworker-runtime/src/module-proxies.ts @@ -65,6 +65,7 @@ export const MODULE_PROXIES: Record = { 'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts', 'node:sqlite': './node/builtin_modules/mock/sqlite.ts', // External npm replacements, named after the package each stands in for. + 'fs-ext': './node/external_packages/fs-ext.ts', 'koffi': './node/external_packages/koffi.ts', 'sharp': './node/external_packages/sharp.ts', 'node-pty': './node/external_packages/node-pty.ts', diff --git a/packages/experimental/webworker-runtime/src/node/builtins.ts b/packages/experimental/webworker-runtime/src/node/builtins.ts index 9f7947dc06..ac44024aec 100644 --- a/packages/experimental/webworker-runtime/src/node/builtins.ts +++ b/packages/experimental/webworker-runtime/src/node/builtins.ts @@ -45,6 +45,7 @@ import * as nodeNet from './builtin_modules/mock/net.ts' import * as nodeSqlite from './builtin_modules/mock/sqlite.ts' import * as nodeVm from './builtin_modules/mock/vm.ts' import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts' +import * as fsExt from './external_packages/fs-ext.ts' import * as koffi from './external_packages/koffi.ts' import * as nodePty from './external_packages/node-pty.ts' import * as piAi from './external_packages/pi-ai.ts' @@ -85,6 +86,7 @@ const BUILTINS: Record = { /** External npm packages replaced wholesale (structural not-implemented stubs and fakes). */ const EXTERNALS: Record = { + 'fs-ext': () => fsExt, 'koffi': () => koffi, 'sharp': () => sharp, 'node-pty': () => nodePty, diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/fs-ext.ts b/packages/experimental/webworker-runtime/src/node/external_packages/fs-ext.ts new file mode 100644 index 0000000000..049aa5c2b5 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/external_packages/fs-ext.ts @@ -0,0 +1,42 @@ +/** + * `fs-ext` stub: the kernel file-lock bridge the JSONL session backend uses + * for cross-process write exclusion. The worker is a single-process + * deployment whose in-process write claim already excludes every writer, so + * both flock faces succeed immediately; every other entry is loud because + * nothing in the worker reaches it. + */ +import { notImplementedFail } from '../notImplementedFail.ts' + +const MODULE = 'fs-ext' + +/** + * Asynchronous flock face; the single-process worker grants every lock. + * @param _fd - file descriptor (unused). + * @param _flags - lock flags (unused). + * @param callback - completion callback, invoked with no error. + */ +export function flock(_fd: number, _flags: unknown, callback: (error: null) => void): void { + queueMicrotask(() => { callback(null) }) +} + +/** + * Synchronous flock face; the single-process worker grants every lock. + */ +export function flockSync(): void {} + +/** Unreached in the worker; loud refusal. */ +export const fcntl = notImplementedFail(MODULE, 'fcntl') +/** Unreached in the worker; loud refusal. */ +export const fcntlSync = notImplementedFail(MODULE, 'fcntlSync') +/** Unreached in the worker; loud refusal. */ +export const seek = notImplementedFail(MODULE, 'seek') +/** Unreached in the worker; loud refusal. */ +export const seekSync = notImplementedFail(MODULE, 'seekSync') +/** Unreached in the worker; loud refusal. */ +export const statVFS = notImplementedFail(MODULE, 'statVFS') + +/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */ +export const __esModule = true + +/** The fs-ext face its consumers read. */ +export default { flock, flockSync, fcntl, fcntlSync, seek, seekSync, statVFS } diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts index 528e6f6885..453e9864f6 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts @@ -10,6 +10,7 @@ export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [ '@earendil-works/pi-ai', '@vscode/ripgrep', + 'fs-ext', 'koffi', 'node-pty', 'sharp', diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index e047e169a4..384ad56eed 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 62735e5f7d8124d5d26af16eb9c21b7c91f850d3 -README.zh.md: b40d00495c61109ec3acf85142e396982c45603f +README.md: 1632b971c1577c7c406fdbd18b732add1c97bfee +README.zh.md: 289e3f3aefff5b98c4053d7682c2fb50f8692c71 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 62735e5f7d..1632b971c1 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -152,7 +152,7 @@ These limits define when this backend is a poor fit or needs special operational - **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally; the seam has no deletion API. -- **One live writer per session, in-process only** — the write-handle claim excludes a second writer inside the owning backend instance; another instance or process must not write the same session until that handle closes (the durable cross-process lease is the seam's planned next layer). +- **One live writer per session** — the write-handle claim excludes a second writer inside the owning backend instance, and a kernel lock (non-blocking `flock(2)` on `session.lock`; on Windows a named kernel semaphore derived from that path, with no filesystem footprint) excludes every other instance and process; the lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write, so an unmaterialized session leaves no filesystem footprint. A crashed holder's lock dies with its process, so its session is writable again immediately, while a live-but-wedged holder blocks writers until its process exits (on POSIX, removing the lock file forfeits that exclusion; release itself never removes it). Advisory `flock` is unreliable on some network filesystems (NFSv3), and the Windows semaphore name is per login session. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index b40d00495c..289e3f3aef 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -152,7 +152,7 @@ JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope - **平铺文件存储布局不加载**——加载前使用独立根,或将预发布产物移入项目/会话目录布局。 - **压缩文件不能直接按行读取**——使用后端加载;或在写入新根前选择 `compression: 'none'`,供外部行读取方使用。 - **不删除会话文件**——日志在 `root` 下累积,直到外部移除;seam 无删除接口。 -- **每会话一个活动写入方,仅限进程内**——写句柄认领只在所属后端实例内排除第二个写入方;在该句柄关闭前,另一实例或进程不得写入同一会话(持久的跨进程租约是该 seam 计划中的下一层)。 +- **每会话一个活动写入方**——写句柄认领在所属后端实例内排除第二个写入方,内核锁(`session.lock` 上的非阻塞 `flock(2)`;Windows 上为由该路径派生的命名内核信号量,零文件系统足迹)排除其他所有实例与进程;锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取,因此未物化的会话不留任何文件系统足迹。崩溃持有者的锁随其进程消亡,会话立即可再写入,而活着但卡死的持有者会阻塞写入方直到其进程退出(POSIX 上删除锁文件即放弃该排他;释放本身从不删除它)。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠,Windows 信号量名按登录会话隔离。 - **POSIX 实体化需要硬链接支持**——第一次 append 使用 `link()`,使同 id 竞态失败而不覆盖已提交日志;Windows 使用无替换 write-through rename。 diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index bfcd2fe81c..71bfddd56a 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -34,6 +34,7 @@ "dependencies": { "@deepseek-ai/dsh-session-format": "workspace:^", "@deepseek-ai/dsh-session-format-catalog": "workspace:^", + "fs-ext": "2.1.1", "koffi": "^3.1.0", "@deepseek-ai/schemastery": "workspace:^" }, @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@types/fs-ext": "2.0.3" } } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 9babc5d855..fa0fa221ca 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -30,6 +30,7 @@ import { type SessionPersistenceRevision as PersistenceRevision, } from '@deepseek-ai/dsh-session-persistence' import { JsonlBackendTracker, JsonlSessionHandle } from './storage.ts' +import { SessionWriteLease } from './lease.ts' import { SESSION_FORMAT_VERSION, SessionId as makeSessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionHeader, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import { @@ -228,6 +229,10 @@ class JsonlSessionPersistence extends SessionPersistence { throw new SessionAlreadyExistsError(snapshot.id) } options?.signal?.throwIfAborted() + // No lock yet: before materialization there is no durable artifact for + // another process to contend over, so the handle acquires the lock right + // before its first log bytes publish (ensureLease); an unmaterialized + // session leaves no filesystem footprint at all. this.tracker.registerCreated(snapshot, inheritedEventCount) return this.tracker.adopt(new JsonlSessionHandle(this, snapshot.id, snapshot, 'write', { cursor: 0, materialized: false, inheritedEventCount })) } @@ -258,7 +263,11 @@ class JsonlSessionPersistence extends SessionPersistence { // A pending entry always belongs to an ACTIVE creator handle (close erases // it), so the claim below rejects that case as already owned. this.tracker.claimWrite(id) + let lease: SessionWriteLease | undefined try { + const resolved = await this.findLog(id, options?.signal) + if (resolved === undefined) throw new SessionPersistenceNotFoundError(id) + lease = await this.acquireLease(id, undefined, dirname(resolved.currentPath)) const stored = await this.requireStoredLog(id, options?.signal) return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'write', { cursor: stored.events.length, @@ -267,10 +276,25 @@ class JsonlSessionPersistence extends SessionPersistence { recoveredTail: stored.recoveredTail, inheritedEventCount: stored.inheritedEventCount, primed: stored.events, - })) + }, lease)) } catch (error) { + // Free the in-process claim no matter how the kernel-lock release + // fares, and keep the original diagnostic: a release failure joins it + // instead of replacing it. + /* v8 ignore next -- typed backends and fs reject with Error */ + const failure = error instanceof Error ? error : new Error(String(error)) + let releaseFailure: Error | undefined + try { + await lease?.release() + } catch (raw: unknown) { + /* v8 ignore next -- lock releases reject with Error */ + releaseFailure = raw instanceof Error ? raw : new Error(String(raw)) + } this.tracker.releaseClaim(id) - throw error + if (releaseFailure !== undefined) { + throw new AggregateError([failure, releaseFailure], `session "${id}": write open failed and its lock release failed`) + } + throw failure } } @@ -593,6 +617,31 @@ class JsonlSessionPersistence extends SessionPersistence { this.tracker.release(handle, materialized) } + /** + * Acquire the session directory's kernel write lock; the kernel holds it + * until the handle's close releases the descriptor, including on process death. + * @param id - the session the lock guards. + * @param cwd - header cwd used to derive the directory for a fresh session. + * @param dir - the resolved directory of an existing artifact, when known. + * @returns the held lock. + */ + private acquireLease(id: SessionId, cwd: string | undefined, dir = sessionDir(this.root, cwd, id)): Promise { + return SessionWriteLease.acquire(dir, id) + } + + /** + * Acquire the cross-process write lock for a materializing created session, + * called by its handle immediately before the first log bytes publish. + * @param header - the session's stored header (its cwd derives the directory). + * @returns the held lock. + */ + async acquireWriteLease(header: SessionHeader): Promise { + // Refuse an opposite-encoding artifact before the lock's mkdir publishes + // the session directory — the last moment the directory can be absent. + await this.rejectOppositeArtifact(header.cwd, header.id) + return this.acquireLease(header.id, header.cwd) + } + /** Decode complete frames and retain complete JSONL records from a torn final frame. */ private async readZstdPrefix( buffer: Buffer, diff --git a/packages/session/session-persistence-jsonl/src/lease.ts b/packages/session/session-persistence-jsonl/src/lease.ts new file mode 100644 index 0000000000..033f96c5c4 --- /dev/null +++ b/packages/session/session-persistence-jsonl/src/lease.ts @@ -0,0 +1,145 @@ +/** + * Cross-process write-ownership lock for one session's artifact directory, + * held for the whole life of a write handle. The arbiter is the kernel: + * POSIX takes a non-blocking `flock(2)` (through fs-ext) on `session.lock` + * beside the log, and Windows holds a named kernel semaphore derived from + * that path — never a file lock or handle, so readers, searches, and + * directory removal proceed freely while the lock is held. Contention maps + * to `SessionAlreadyOwnedError`; the kernel releases the lock when the + * holder's descriptor or last object handle closes, including on any process + * death, so a crashed holder never blocks a successor. A live but wedged + * holder keeps the lock until its process exits: there is deliberately no + * expiry that could expropriate a stalled writer whose resumed appends would + * tear the log. + * A POSIX lock names an inode, not a path, so after locking the holder + * verifies the locked inode is still the file at the lock path and retries + * otherwise: an unlinked-and-recreated lock file carries a fresh inode, and + * a lock on the orphaned one proves nothing. Removing a live session's lock + * file therefore forfeits exclusion on POSIX (nothing in the harness does + * so); Windows has no lock file at all. Readers never touch the lock. + * The lock is acquired at write-open of an existing artifact and, for a + * created session, only right before its first materializing write — an + * unmaterialized session has no filesystem footprint. Release never removes + * the POSIX lock file: every acquired lock belongs to a materialized or + * materializing session, and the surviving file keeps the stable inode later + * lockers verify against. The browser worker deployment stubs fs-ext to + * immediate success: it is single-process, so the in-process write claim + * already excludes every writer. + * @module @deepseek-ai/dsh-session-persistence-jsonl/lease + */ + +import { mkdir, open, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { join } from 'node:path' +import { flock } from 'fs-ext' +import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { acquireLockHandleWin32, releaseLockHandleWin32 } from './win32.ts' + +/** Base name of the kernel lock file inside a session's directory. */ +export const LEASE_FILENAME = 'session.lock' + +/** The held kernel lock: a POSIX descriptor or a Win32 semaphore handle. */ +type HeldLock = + | { readonly kind: 'posix'; readonly handle: FileHandle } + | { readonly kind: 'win32'; readonly handle: number } + +/** Promise face over fs-ext's callback flock, pinned to its string-flag overload. */ +function flockAsync(fd: number, flags: 'exnb' | 'un'): Promise { + return new Promise((resolve, reject) => { + flock(fd, flags, (error) => { + if (error) reject(error) + else resolve() + }) + }) +} + +/** Whether a flock failure means another descriptor holds the lock. */ +function isLockContention(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + // flock(2) reports EAGAIN; some libcs spell it EWOULDBLOCK. + return code === 'EAGAIN' || code === 'EWOULDBLOCK' +} + +/** + * One held write lock. Constructed only by {@link SessionWriteLease.acquire}; + * `release` closes the descriptor or handle, which is what releases the lock. + */ +export class SessionWriteLease { + private released = false + + private constructor(private readonly held: HeldLock) {} + + /** + * Acquire the session directory's kernel write lock. + * @param dir - the session's artifact directory (created if absent). + * @param id - the session the lock guards, for error identities. + * @returns the held lock. + * @throws {SessionAlreadyOwnedError} while another holder keeps the lock. + */ + static async acquire(dir: string, id: SessionId): Promise { + const path = join(dir, LEASE_FILENAME) + // Owner-only like materializePosix's directories: the lock may create the + // session directory first, and both creators must agree on the mode. + await mkdir(dir, { recursive: true, mode: 0o700 }) + /* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */ + if (process.platform === 'win32') { + let handle: number + try { + handle = await acquireLockHandleWin32(path) + } catch (error: unknown) { + // Sharing violation: another handle already holds the write exclusion. + if ((error as NodeJS.ErrnoException | null)?.code === 'EBUSY') throw new SessionAlreadyOwnedError(id) + throw error + } + return new SessionWriteLease({ kind: 'win32', handle }) + } + /* v8 ignore stop */ + // Bounded retry: locking an inode a releasing creator just unlinked (or a + // recreated path) re-opens the fresh file; steady state needs one pass. + for (let attempt = 0; attempt < 3; attempt += 1) { + const handle = await open(path, 'w') + try { + try { + await flockAsync(handle.fd, 'exnb') + } catch (error: unknown) { + if (isLockContention(error)) throw new SessionAlreadyOwnedError(id) + throw error + } + const held = await handle.stat({ bigint: true }) + const current = await stat(path, { bigint: true }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined + throw error + }) + if (current !== undefined && current.ino === held.ino && current.dev === held.dev) { + return new SessionWriteLease({ kind: 'posix', handle }) + } + } catch (error: unknown) { + await handle.close() + throw error + } + // The locked inode is no longer the file at the lock path: start over + // against whatever now stands there. + await handle.close() + } + throw new SessionAlreadyOwnedError(id) + } + + /** + * Release the kernel lock by closing its descriptor or handle. The POSIX + * lock file is never removed: every acquired lock belongs to a + * materialized or materializing session, and keeping the file preserves + * the stable inode later lockers verify against. Idempotent. + */ + async release(): Promise { + if (this.released) return + this.released = true + /* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */ + if (this.held.kind === 'win32') { + await releaseLockHandleWin32(this.held.handle) + return + } + /* v8 ignore stop */ + await this.held.handle.close() + } +} diff --git a/packages/session/session-persistence-jsonl/src/storage.ts b/packages/session/session-persistence-jsonl/src/storage.ts index 936355260f..7cb4132cc9 100644 --- a/packages/session/session-persistence-jsonl/src/storage.ts +++ b/packages/session/session-persistence-jsonl/src/storage.ts @@ -14,8 +14,8 @@ import { errorChain } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import { assertContiguous, - materializeAppendBatch, SessionAlreadyExistsError, + materializeAppendBatch, SessionAlreadyOwnedError, SessionHandleClosedError, SessionPersistenceNotFoundError, @@ -29,6 +29,7 @@ import type { SessionHandleFlushOptions, SessionHandleReadOptions, } from '@deepseek-ai/dsh-session-persistence' +import type { SessionWriteLease } from './lease.ts' /** Maximum intentional wait before a routed live session batch starts writing. */ export const LIVE_WRITE_BATCH_MAX_DELAY_MS = 200 @@ -52,6 +53,8 @@ export interface JsonlHandleStorage { readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<{ events: SessionEvent[] }> /** Whether the id is still a created-but-unmaterialized session here. */ hasPendingSession(id: SessionId): boolean + /** Acquire the session's cross-process write lock in its artifact directory. */ + acquireWriteLease(header: SessionHeader): Promise /** Drop the handle's bookkeeping on close. */ releaseHandle(handle: JsonlSessionHandle, materialized: boolean): void } @@ -95,6 +98,8 @@ export class JsonlSessionHandle implements SessionHandle { readonly header: SessionHeader, readonly access: SessionAccess, private readonly state: StorageHandleState, + /** The cross-process write lock; a create handle acquires it lazily at first materialization. */ + private lease?: SessionWriteLease, ) {} /** Exact fork-inherited prefix length stored with this session's log. */ @@ -166,6 +171,7 @@ export class JsonlSessionHandle implements SessionHandle { options?.signal?.throwIfAborted() if (this.access !== 'write') throw new SessionReadOnlyError(this.id, 'flush') if (this.state.materialized) return // appends are durable on resolution + await this.ensureLease() await this.storage.persistHeader(this.header, this.state.inheritedEventCount) this.state.materialized = true }) @@ -175,7 +181,9 @@ export class JsonlSessionHandle implements SessionHandle { * Release the handle; see the seam contract. Idempotent and uncancellable. * A write handle first drains its routed live buffer through the still-open * storage, so backend teardown loses nothing regardless of which fiber - * unwinds first; a drain failure still releases ownership, then rejects. + * unwinds first; a drain or lock-release failure still frees the in-process + * claim, then rejects — both failures together reject as one + * `AggregateError`. * @returns settlement of the release. */ close(): Promise { @@ -198,10 +206,22 @@ export class JsonlSessionHandle implements SessionHandle { } // After a drain failure the chain may still hold in-flight mutations. await this.chain - this.storage.releaseHandle(this, this.state.materialized) + // Free the in-process claim no matter how the kernel-lock release + // fares: a skipped releaseHandle would wedge the id in this process + // behind a lock the kernel may already have dropped. + const failures: Error[] = [] if (drainFailure !== undefined) { - throw drainFailure instanceof Error ? drainFailure : new Error(errorChain(drainFailure)) + failures.push(drainFailure instanceof Error ? drainFailure : new Error(errorChain(drainFailure))) } + try { + await this.lease?.release() + } catch (releaseFailure: unknown) { + /* v8 ignore next -- lock releases reject with Error */ + failures.push(releaseFailure instanceof Error ? releaseFailure : new Error(errorChain(releaseFailure))) + } + this.storage.releaseHandle(this, this.state.materialized) + if (failures.length > 1) throw new AggregateError(failures, `session "${this.id}": close failed to drain and to release its write lock`) + if (failures[0] !== undefined) throw failures[0] })() } @@ -261,10 +281,11 @@ export class JsonlSessionHandle implements SessionHandle { } } - /** The shared durable-append body: contiguity, torn-tail repair, storage write, state advance. */ + /** The shared durable-append body: contiguity, ownership, torn-tail repair, storage write, state advance. */ private async persistContiguous(batch: readonly SessionEvent[]): Promise { if (this.access !== 'write') throw new SessionReadOnlyError(this.id, 'append') if (batch.length === 0) return + await this.ensureLease() assertContiguous(this.id, batch, this.state.cursor) // Commit any pending torn-tail repair first, clearing each step's state // only once it lands so a failed step retries on the next mutation: @@ -287,6 +308,17 @@ export class JsonlSessionHandle implements SessionHandle { this.observedLength = this.state.cursor } + /** + * Hold the cross-process write lock before this session's first durable + * write. An open write handle holds it from construction; a create handle + * acquires it here — immediately before the first log bytes publish — and + * keeps it through close even when materialization then fails, so a + * materializing session stays exclusively owned across retries. + */ + private async ensureLease(): Promise { + this.lease ??= await this.storage.acquireWriteLease(this.header) + } + /** Serialize one operation onto the chain without the closed-handle refusal (drain-from-close). */ private enqueueChain(op: () => Promise): Promise { const next = this.chain.then(op) @@ -335,7 +367,11 @@ export class JsonlBackendTracker { /** * Claim write ownership and record the created session as pending, making - * it observable to this process before it materializes. + * it observable to this process before it materializes. Before + * materialization this registration is the only guard — session ids do not + * collide across processes, and no durable artifact exists for another + * process to open; the handle takes the cross-process lock at its first + * materializing write. * @param header - the validated detached header. * @param inheritedEventCount - the exact fork-inherited prefix length. * @throws {SessionAlreadyExistsError} when a concurrent create or an open diff --git a/packages/session/session-persistence-jsonl/src/win32.ts b/packages/session/session-persistence-jsonl/src/win32.ts index c3fa852b08..2369f474f2 100644 --- a/packages/session/session-persistence-jsonl/src/win32.ts +++ b/packages/session/session-persistence-jsonl/src/win32.ts @@ -11,14 +11,23 @@ * @module dsh-session-persistence-jsonl/win32 */ +import { createHash } from 'node:crypto' import { mkdtemp, rm, stat } from 'node:fs/promises' import { join, parse, resolve, toNamespacedPath } from 'node:path' type MoveFileExW = (existing: string, replacement: string, flags: number) => number +type CreateSemaphoreW = (security: null, initial: number, maximum: number, name: string) => number +type WaitForSingleObject = (handle: number, milliseconds: number) => number +type ReleaseSemaphore = (handle: number, count: number, previous: null) => number +type CloseHandle = (handle: number) => number type GetLastError = () => number interface Win32Bindings { moveFileExW: MoveFileExW + createSemaphoreW: CreateSemaphoreW + waitForSingleObject: WaitForSingleObject + releaseSemaphore: ReleaseSemaphore + closeHandle: CloseHandle getLastError: GetLastError } @@ -28,10 +37,13 @@ interface Win32ErrnoException extends NodeJS.ErrnoException { } const MOVEFILE_WRITE_THROUGH = 0x00000008 +const WAIT_OBJECT_0 = 0 +const WAIT_TIMEOUT = 0x00000102 const ERROR_FILE_NOT_FOUND = 2 const ERROR_PATH_NOT_FOUND = 3 const ERROR_ACCESS_DENIED = 5 const ERROR_NOT_SAME_DEVICE = 17 +const ERROR_SHARING_VIOLATION = 32 const ERROR_FILE_EXISTS = 80 const ERROR_INVALID_NAME = 123 const ERROR_ALREADY_EXISTS = 183 @@ -45,6 +57,10 @@ async function win32(): Promise { const kernel32 = koffi.load('kernel32.dll') bindings = { moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'int', ['str16', 'str16', 'uint']) as MoveFileExW, + createSemaphoreW: kernel32.func('__stdcall', 'CreateSemaphoreW', 'intptr', ['void*', 'int', 'int', 'str16']) as CreateSemaphoreW, + waitForSingleObject: kernel32.func('__stdcall', 'WaitForSingleObject', 'uint', ['intptr', 'uint']) as WaitForSingleObject, + releaseSemaphore: kernel32.func('__stdcall', 'ReleaseSemaphore', 'int', ['intptr', 'int', 'void*']) as ReleaseSemaphore, + closeHandle: kernel32.func('__stdcall', 'CloseHandle', 'int', ['intptr']) as CloseHandle, getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError, } return bindings @@ -59,6 +75,8 @@ function errnoCode(win32Code: number): string { return 'EACCES' case ERROR_NOT_SAME_DEVICE: return 'EXDEV' + case ERROR_SHARING_VIOLATION: + return 'EBUSY' case ERROR_FILE_EXISTS: case ERROR_ALREADY_EXISTS: return 'EEXIST' @@ -119,6 +137,41 @@ export async function publishNewFileWin32(existing: string, replacement: string) if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement) } +/** + * Acquire the session write lock as a named kernel semaphore (count 1) whose + * name is derived from the canonical lock path. A kernel object never touches + * the filesystem, so readers, searches, and directory removal proceed freely + * while the lock is held; a second acquirer's zero-timeout wait times out + * (`EBUSY`); and when the last handle closes — including on any process + * death — the object is destroyed, so a successor's create starts fresh. + * @param path - the lock file path the name is derived from (case-folded: + * Windows paths are case-insensitive). + * @returns the open semaphore handle, released via {@link releaseLockHandleWin32}. + */ +export async function acquireLockHandleWin32(path: string): Promise { + const api = await win32() + const name = `Local\\dsh-session-lock-${createHash('sha256').update(resolve(path).toLowerCase()).digest('hex')}` + const handle = api.createSemaphoreW(null, 1, 1, name) + if (handle === 0) throw win32Error('CreateSemaphoreW', api.getLastError(), path, name) + const wait = api.waitForSingleObject(handle, 0) + if (wait === WAIT_OBJECT_0) return handle + api.closeHandle(handle) + if (wait === WAIT_TIMEOUT) throw win32Error('WaitForSingleObject', ERROR_SHARING_VIOLATION, path, name) + throw win32Error('WaitForSingleObject', api.getLastError(), path, name) +} + +/** + * Release a lock from {@link acquireLockHandleWin32}: restore the semaphore + * count and close the handle (the object dies with its last handle). + * @param handle - the open semaphore handle. + */ +export async function releaseLockHandleWin32(handle: number): Promise { + const api = await win32() + const released = api.releaseSemaphore(handle, 1, null) + const closed = api.closeHandle(handle) + if (released === 0 || closed === 0) throw win32Error('ReleaseSemaphore', api.getLastError(), `handle:${handle}`, `handle:${handle}`) +} + /** * Create `target` and its missing ancestors with durable Windows namespace * publication. Each missing directory is first created as a random staging diff --git a/packages/session/session-persistence-jsonl/tests/fixtures/lease-holder.mjs b/packages/session/session-persistence-jsonl/tests/fixtures/lease-holder.mjs new file mode 100644 index 0000000000..613f06fa8e --- /dev/null +++ b/packages/session/session-persistence-jsonl/tests/fixtures/lease-holder.mjs @@ -0,0 +1,28 @@ +/** + * Two-process lock e2e holder: creates one session over the given root, + * materializes two events, prints `holding`, and keeps its kernel write lock + * until the parent SIGKILLs this process (a crash: release never runs). + * Runs the built package under plain Node. + */ + +import { Context } from '@deepseek-ai/cordis' +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' + +const [root, sessionId] = process.argv.slice(2) +const ctx = new Context() +await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) +const handle = await ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: sessionId, + createdAt: 1000, + cwd: '/work', + isSeeded: false, +}) +await handle.append([ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, +]) +process.stdout.write('holding\n') +// Keep the descriptor (and with it the kernel lock) until killed; never close. +setInterval(() => {}, 1000) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 24f4e82d12..581a964c8e 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -2240,15 +2240,14 @@ describe('JsonlSessionPersistence: edge cases', () => { await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/) }) - it('materialization surfaces a project-directory storage fault', async () => { + it('a project-directory storage fault surfaces at the first materializing write', async () => { const cwd = '/x' await writeFile(projectDir(root, cwd), 'x') // project path is now a file + // Create touches no storage; the lock acquisition ahead of the first + // materializing append walks into the fault. const handle = await ctx.sessionPersistence.create(meta('exists-fault', cwd)) - try { - await expectCode(handle.append(oneTurnLog()), ['EEXIST', 'ENOTDIR']) - } finally { - await handle.close() - } + await expectCode(handle.append([{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }]), ['EEXIST', 'ENOTDIR']) + await handle.close() }) it('backend teardown closes handles left open and fails later operations loudly', async () => { diff --git a/packages/session/session-persistence-jsonl/tests/lease.spec.ts b/packages/session/session-persistence-jsonl/tests/lease.spec.ts new file mode 100644 index 0000000000..b792b937f2 --- /dev/null +++ b/packages/session/session-persistence-jsonl/tests/lease.spec.ts @@ -0,0 +1,437 @@ +/** + * Cross-process write-lock behavior, exercised through fresh backend + * instances over one shared root: kernel `flock` locks conflict between two + * descriptors even inside one process, so a second instance behaves exactly + * like a second process. Exclusion while a holder is live, immediate + * admission after close, lock-file residue rules, and the inode verification + * that defeats an unlinked-and-recreated lock path. Filesystem and flock + * refusals are injected through the module mocks below: POSIX modes cannot + * express them on Windows, and an injected error is the only deterministic + * cross-platform refusal. Real cross-process exclusion and crash release are + * pinned by lease.two-process.e2e.ts. + */ + +import { existsSync } from 'node:fs' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import { + SessionAlreadyExistsError, + SessionAlreadyOwnedError, + SessionPersistenceNotFoundError, +} from '@deepseek-ai/dsh-session-persistence' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import JsonlSessionPersistence from '../src/index.ts' +import { LEASE_FILENAME, SessionWriteLease } from '../src/lease.ts' +import type { JsonlSessionHandle } from '../src/storage.ts' +import { sessionDir } from '../src/format.ts' + +// The lock's base name, duplicated for the hoisted mock factories: they run +// while `../src/lease.ts` is still evaluating, before LEASE_FILENAME exists. +const LOCK = vi.hoisted(() => 'session.lock') + +const refuse = vi.hoisted(() => ({ + /** Next open of a lock file fails EACCES (read-only directory). */ + lockOpen: false, + /** Next flock call fails EACCES (a non-contention kernel refusal). */ + flock: false, + /** Next flock call fails EWOULDBLOCK (the Windows LockFileEx contention code). */ + flockBusy: false, + /** Next stat of a lock file fails EACCES (unreadable path). */ + lockStat: false, + /** For N further lock-path stats: unlink and recreate the file first, so the locked inode is orphaned. */ + swapLockOnStat: 0, + /** Next lock-path stat: unlink the file first, so the verify read finds nothing. */ + dropLockOnStat: false, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + const denied = (syscall: string): never => { + throw Object.assign(new Error(`EACCES: injected ${syscall} refusal`), { code: 'EACCES' }) + } + return { + ...actual, + open: (async (path: unknown, ...rest: never[]) => { + if (refuse.lockOpen && String(path).endsWith(LOCK)) { + refuse.lockOpen = false + denied('open') + } + return (actual.open as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.open, + stat: (async (path: unknown, ...rest: never[]) => { + const at = String(path) + if (at.endsWith(LOCK)) { + if (refuse.lockStat) { + refuse.lockStat = false + denied('stat') + } + if (refuse.dropLockOnStat) { + refuse.dropLockOnStat = false + await actual.unlink(at) + } else if (refuse.swapLockOnStat > 0) { + refuse.swapLockOnStat -= 1 + await actual.unlink(at) + await actual.writeFile(at, '') + } + } + return (actual.stat as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.stat, + } +}) + +vi.mock('fs-ext', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + flock: ((fd: number, flags: never, callback: (error: Error | null) => void) => { + if (refuse.flock) { + refuse.flock = false + callback(Object.assign(new Error('EACCES: injected flock refusal'), { code: 'EACCES' })) + return + } + if (refuse.flockBusy) { + refuse.flockBusy = false + callback(Object.assign(new Error('EWOULDBLOCK: injected contention'), { code: 'EWOULDBLOCK' })) + return + } + (actual.flock as (fd: number, flags: never, callback: (error: Error | null) => void) => void)(fd, flags, callback) + }) as typeof actual.flock, + } +}) + +const dirs: string[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + refuse.lockOpen = false + refuse.flock = false + refuse.flockBusy = false + refuse.lockStat = false + refuse.swapLockOnStat = 0 + refuse.dropLockOnStat = false + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + +function meta(id: string, cwd = '/work'): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: 1_000, cwd, isSeeded: false } +} + +async function freshRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-lease-')) + dirs.push(root) + return root +} + +async function mount(root: string): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + return ctx.sessionPersistence +} + +function lockPath(root: string, id: string, cwd = '/work'): string { + return join(sessionDir(root, cwd, SessionId(id)), LEASE_FILENAME) +} + +/** + * Make the next lock release do its real work, then report failure — as a + * close(2) that freed the descriptor but returned EIO would. + */ +function failReleaseOnce(): void { + const spy = vi.spyOn(SessionWriteLease.prototype, 'release') + spy.mockImplementationOnce(async function (this: SessionWriteLease) { + spy.mockRestore() + await this.release() + throw Object.assign(new Error('EIO: injected release failure'), { code: 'EIO' }) + }) +} + +const EVENTS = [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, +] as const + +describe('cross-process write lock', () => { + it('excludes a second instance while the holder is live, and admits it after close', async () => { + const root = await freshRoot() + const first = await mount(root) + const second = await mount(root) + const holder = await first.create(meta('excluded')) + await holder.append([...EVENTS]) + + // Another instance over the same root cannot create or write-open the id: + // the materialized duplicate is an existence fact, the write open an + // ownership one. + await expect(second.create(meta('excluded'))).rejects.toBeInstanceOf(SessionAlreadyExistsError) + await expect(second.open(SessionId('excluded'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + // Unmaterialized creates hold no lock and leave no artifact, so a rival + // instance's create succeeds; the collision surfaces at the loser's first + // materializing write, where the winner already holds the lock. + const pendingWinner = await first.create(meta('excluded-pending')) + const pendingLoser = await second.create(meta('excluded-pending')) + await pendingWinner.append([...EVENTS]) + await expect(pendingLoser.append([...EVENTS])).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + await pendingLoser.close() + await pendingWinner.close() + // Reads never touch the lock. + const reader = await second.open(SessionId('excluded'), 'read') + expect((await reader.read()).map(event => event.seq)).toEqual([0, 1]) + await reader.close() + + await holder.close() + // POSIX keeps the materialized session's lock file (Windows locks a kernel + // object with no filesystem footprint); the kernel lock itself is gone. + if (process.platform !== 'win32') expect(existsSync(lockPath(root, 'excluded'))).toBe(true) + const reopened = await second.open(SessionId('excluded'), 'write') + await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await reopened.close() + }) + + it.skipIf(process.platform === 'win32')('removing the lock file forfeits a wedged holder: a fresh inode admits a successor', async () => { + const root = await freshRoot() + const first = await mount(root) + const second = await mount(root) + const wedged = await first.create(meta('wedged')) + await wedged.append([...EVENTS]) + + // The documented escape hatch for a live-but-stuck holder: deleting the + // lock file orphans the held inode, and a successor locks the fresh one. + await rm(lockPath(root, 'wedged')) + const successor = await second.open(SessionId('wedged'), 'write') + await successor.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await successor.close() + await wedged.close() + }) + + it('write-opening an absent session leaves no lock residue', async () => { + const root = await freshRoot() + const backend = await mount(root) + await expect(backend.open(SessionId('absent'), 'write')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError) + expect(existsSync(join(root, LEASE_FILENAME))).toBe(false) + }) + + it('write-opening an absent id under an existing project directory reports not-found', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('present-sibling')) + await writer.append([...EVENTS]) + await writer.close() + // The project directory exists but the id's session directory does not: + // the generation scan reports absence rather than misreading a sibling. + await expect(backend.open(SessionId('absent-sibling'), 'write')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError) + }) + + it('a never-materialized create leaves no filesystem footprint at all', async () => { + const root = await freshRoot() + const backend = await mount(root) + const handle = await backend.create(meta('erased')) + // The lock is taken only at the first materializing write, so an + // unmaterialized session creates neither its directory nor a lock file. + expect(existsSync(join(lockPath(root, 'erased'), '..'))).toBe(false) + await handle.close() + expect(existsSync(join(lockPath(root, 'erased'), '..'))).toBe(false) + await expect(backend.stat(SessionId('erased'))).resolves.toBeUndefined() + }) + + it('materialization publishes the lock before the first log bytes and keeps it on the handle', async () => { + const root = await freshRoot() + const first = await mount(root) + const second = await mount(root) + const creator = await first.create(meta('lazy-lock')) + await creator.append([...EVENTS]) + // The materializing append acquired and retained the lock. + if (process.platform !== 'win32') expect(existsSync(lockPath(root, 'lazy-lock'))).toBe(true) + await expect(second.open(SessionId('lazy-lock'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + // A later append reuses the held lock rather than re-acquiring. + await creator.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await creator.close() + const reopened = await second.open(SessionId('lazy-lock'), 'write') + await reopened.close() + }) + + it('an explicitly flushed empty session takes the lock with its header', async () => { + const root = await freshRoot() + const first = await mount(root) + const second = await mount(root) + const creator = await first.create(meta('flush-lock')) + await creator.flush() + await expect(second.open(SessionId('flush-lock'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + await creator.close() + }) + + it.skipIf(process.platform === 'win32')('surfaces a filesystem refusal opening the lock file', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('open-blocked')) + await writer.append([...EVENTS]) + await writer.close() + + refuse.lockOpen = true + await expect(backend.open(SessionId('open-blocked'), 'write')).rejects.toThrow(/EACCES/) + }) + + it.skipIf(process.platform === 'win32')('surfaces a non-contention flock failure', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('flock-blocked')) + await writer.append([...EVENTS]) + await writer.close() + + refuse.flock = true + await expect(backend.open(SessionId('flock-blocked'), 'write')).rejects.toThrow(/EACCES/) + }) + + it.skipIf(process.platform === 'win32')('maps the EWOULDBLOCK contention spelling to already-owned', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('win-contended')) + await writer.append([...EVENTS]) + await writer.close() + + // Some libcs spell flock(2) contention EWOULDBLOCK rather than EAGAIN. + refuse.flockBusy = true + await expect(backend.open(SessionId('win-contended'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + }) + + it.skipIf(process.platform === 'win32')('surfaces a lock-path stat refusal from the inode verification', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('stat-blocked')) + await writer.append([...EVENTS]) + await writer.close() + + refuse.lockStat = true + await expect(backend.open(SessionId('stat-blocked'), 'write')).rejects.toThrow(/EACCES/) + }) + + it.skipIf(process.platform === 'win32')('retries when the locked inode is no longer the lock path, and wins on a stable pass', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('churned')) + await writer.append([...EVENTS]) + await writer.close() + + // One churn (unlink+recreate under the verify stat) orphans the first + // locked inode; the retry locks the fresh file and verifies clean. + refuse.swapLockOnStat = 1 + const reopened = await backend.open(SessionId('churned'), 'write') + await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await reopened.close() + }) + + it.skipIf(process.platform === 'win32')('retries when the lock path vanishes under the verify read', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('vanished')) + await writer.append([...EVENTS]) + await writer.close() + + refuse.dropLockOnStat = true + const reopened = await backend.open(SessionId('vanished'), 'write') + await reopened.close() + }) + + it.skipIf(process.platform === 'win32')('gives up as already-owned when the lock path never stabilizes', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('unstable')) + await writer.append([...EVENTS]) + await writer.close() + + // Churn on every attempt: the bounded retry refuses rather than spinning. + refuse.swapLockOnStat = 3 + await expect(backend.open(SessionId('unstable'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + }) + + it('a failing lock release still frees the in-process claim on close', async () => { + const root = await freshRoot() + const backend = await mount(root) + const holder = await backend.create(meta('release-fails')) + await holder.append([...EVENTS]) + + failReleaseOnce() + await expect(holder.close()).rejects.toThrow(/injected release failure/) + // The claim is freed despite the failed release: the id is not wedged. + const reopened = await backend.open(SessionId('release-fails'), 'write') + await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await reopened.close() + }) + + it('a write-open failure with a failing release aggregates both and frees the claim', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('open-and-release-fail')) + await writer.append([...EVENTS]) + await writer.close() + // Corrupt the stored header line so the open fails after the lock is + // acquired (a garbled tail would be recovered as torn, not refused). + const dir = join(lockPath(root, 'open-and-release-fail'), '..') + const log = (await readdir(dir)).find(name => name.endsWith('.jsonl')) + const stored = await readFile(join(dir, String(log)), 'utf8') + await writeFile(join(dir, String(log)), `#${stored.slice(1)}`) + + failReleaseOnce() + const outcome = await backend.open(SessionId('open-and-release-fail'), 'write').then(() => undefined, (error: unknown) => error) + expect(outcome).toBeInstanceOf(AggregateError) + const errors = (outcome as AggregateError).errors as Error[] + expect(errors).toHaveLength(2) + expect(String(errors[0])).toMatch(/corrupt/i) + expect(String(errors[1])).toMatch(/injected release failure/) + // The original diagnostic survives, and the claim is freed: the next + // attempt reports the corruption again rather than a phantom owner. + await expect(backend.open(SessionId('open-and-release-fail'), 'write')).rejects.toThrow(/corrupt/i) + }) + + it('a drain failure and a release failure reject close as one AggregateError', async () => { + const root = await freshRoot() + const backend = await mount(root) + const holder = await backend.create(meta('drain-and-release-fail')) as unknown as JsonlSessionHandle + await holder.append([...EVENTS]) + + vi.spyOn(backend as unknown as { persistBatch: () => Promise }, 'persistBatch') + .mockRejectedValueOnce(new Error('injected drain refusal')) + holder.enqueueLive({ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }, () => {}) + failReleaseOnce() + const outcome = await holder.close().then(() => undefined, (error: unknown) => error) + expect(outcome).toBeInstanceOf(AggregateError) + expect((outcome as AggregateError).errors.map(String).join('\n')).toMatch(/drain refusal[\s\S]*release failure/) + // Both failures reported, and the id is still not wedged. + const reopened = await backend.open(SessionId('drain-and-release-fail'), 'write') + await reopened.close() + }) + + it.skipIf(process.platform === 'win32')('release is idempotent and never removes the lock file', async () => { + const root = await freshRoot() + const dir = join(root, 'solo') + const lease = await SessionWriteLease.acquire(dir, SessionId('solo')) + await lease.release() + await lease.release() + // The file survives every release, keeping the stable inode later + // lockers verify against; the kernel lock died with the descriptor. + expect(existsSync(join(dir, LOCK))).toBe(true) + const successor = await SessionWriteLease.acquire(dir, SessionId('solo')) + await successor.release() + expect(existsSync(join(dir, LOCK))).toBe(true) + }) + + + it('keeps distinct sessions independently lockable', async () => { + const root = await freshRoot() + const backend = await mount(root) + const a = await backend.create(meta('indep-a')) + const b = await backend.create(meta('indep-b')) + await a.append([...EVENTS]) + await b.append([...EVENTS]) + if (process.platform !== 'win32') { + expect((await readdir(join(lockPath(root, 'indep-a'), '..'))).filter(name => name === LOCK)).toHaveLength(1) + } + await a.close() + await b.close() + }) +}) diff --git a/packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts b/packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts new file mode 100644 index 0000000000..9b6bfe8f68 --- /dev/null +++ b/packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts @@ -0,0 +1,69 @@ +/** + * Real two-process lock contention over one shared root: a child Node + * process (running the built package under plain Node) creates a session and + * holds its kernel write lock; this process is excluded while the child + * lives, and acquires immediately after a SIGKILL — the kernel releases the + * lock with the dead process's descriptors, no waiting period. Keyless. + */ + +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' + +const SESSION = 'two-process-lease' + +const dirs: string[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + +const HOLDER = fileURLToPath(new URL('./fixtures/lease-holder.mjs', import.meta.url)) + +describe('two-process write lock (built lib)', () => { + it('excludes a live holder process and takes over immediately after its crash', { timeout: 30_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-lease-2proc-')) + dirs.push(root) + + const holder = spawn(process.execPath, [HOLDER, root, SESSION], { + stdio: ['ignore', 'pipe', 'inherit'], + }) + const exited = new Promise((resolve) => { holder.once('exit', () => { resolve() }) }) + try { + await once(holder.stdout, 'data') // 'holding' + + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + const mine = ctx.sessionPersistence + + // Excluded while the other process's descriptor holds the kernel lock. + await expect(mine.open(SessionId(SESSION), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + // Reads are unaffected across processes. + const reader = await mine.open(SessionId(SESSION), 'read') + expect((await reader.read()).map(event => event.seq)).toEqual([0, 1]) + await reader.close() + + // Crash the holder: no release runs, but the kernel drops the lock with + // the process, so takeover succeeds without any waiting period. + holder.kill('SIGKILL') + await exited + const taken = await mine.open(SessionId(SESSION), 'write') + await taken.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + expect((await taken.read()).map(event => event.seq)).toEqual([0, 1, 2]) + await taken.close() + } finally { + if (holder.exitCode === null) holder.kill('SIGKILL') + } + }) +}) diff --git a/packages/session/session-persistence-jsonl/tests/win32.spec.ts b/packages/session/session-persistence-jsonl/tests/win32.spec.ts index 3b6cfc4f78..aa78cd6d49 100644 --- a/packages/session/session-persistence-jsonl/tests/win32.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/win32.spec.ts @@ -208,3 +208,81 @@ describe('Windows durable namespace helpers', () => { await expect(ensureDurableDirectoryWin32(join(blocked, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' }) }) }) + +async function importWithLock(bindings: { + createSemaphoreW?: (name: string, initial: number, maximum: number) => number + waitResult?: number + releaseSemaphore?: (handle: number) => number + closeHandle?: (handle: number) => number + lastError?: number +}): Promise { + vi.resetModules() + vi.doMock('koffi', () => ({ + default: { + load: () => ({ + func: (_convention: string, name: string) => { + if (name === 'CreateSemaphoreW') { + return (_security: null, initial: number, maximum: number, semName: string) => + (bindings.createSemaphoreW ?? (() => 7))(semName, initial, maximum) + } + if (name === 'WaitForSingleObject') return () => bindings.waitResult ?? 0 + if (name === 'ReleaseSemaphore') return bindings.releaseSemaphore ?? (() => 1) + if (name === 'CloseHandle') return bindings.closeHandle ?? (() => 1) + if (name === 'MoveFileExW') return () => 1 + return () => bindings.lastError ?? 0 // GetLastError + }, + }), + }, + })) + return import('../src/win32.ts') +} + +describe('Windows write-lock semaphore', () => { + it('acquires a path-derived named semaphore with a zero-timeout wait', async () => { + const created: Array<{ name: string; initial: number; maximum: number }> = [] + const { acquireLockHandleWin32 } = await importWithLock({ + createSemaphoreW: (name, initial, maximum) => { + created.push({ name, initial, maximum }) + return 7 + }, + }) + await expect(acquireLockHandleWin32('C:\\s\\session.lock')).resolves.toBe(7) + expect(created).toHaveLength(1) + // Count-1 semaphore in the login-session namespace, named by path hash: + // no filesystem footprint, and case-insensitive like Windows paths. + expect(created[0]).toMatchObject({ initial: 1, maximum: 1 }) + expect(created[0]?.name).toMatch(/^Local\\dsh-session-lock-[0-9a-f]{64}$/) + const upper = await importWithLock({ createSemaphoreW: (name) => { created.push({ name, initial: 1, maximum: 1 }); return 7 } }) + await upper.acquireLockHandleWin32('C:\\S\\SESSION.LOCK') + expect(created[1]?.name).toBe(created[0]?.name) + }) + + it('maps a held semaphore (wait timeout) to EBUSY and closes the probe handle', async () => { + const closed: number[] = [] + const { acquireLockHandleWin32 } = await importWithLock({ + waitResult: 0x102, + closeHandle: (handle) => { closed.push(handle); return 1 }, + }) + await expect(acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EBUSY' }) + expect(closed).toEqual([7]) + }) + + it('surfaces create and wait failures with Win32 codes', async () => { + const createFailed = await importWithLock({ createSemaphoreW: () => 0, lastError: 5 }) + await expect(createFailed.acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 }) + const waitFailed = await importWithLock({ waitResult: 0xffffffff, lastError: 5 }) + await expect(waitFailed.acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 }) + }) + + it('releases by restoring the count and closing, surfacing a failed release', async () => { + const order: string[] = [] + const working = await importWithLock({ + releaseSemaphore: (handle) => { order.push(`release:${handle}`); return 1 }, + closeHandle: (handle) => { order.push(`close:${handle}`); return 1 }, + }) + await working.releaseLockHandleWin32(7) + expect(order).toEqual(['release:7', 'close:7']) + const failing = await importWithLock({ releaseSemaphore: () => 0, lastError: 5 }) + await expect(failing.releaseLockHandleWin32(9)).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 }) + }) +}) diff --git a/packages/session/session-persistence/tests/live-write-contract.ts b/packages/session/session-persistence/tests/live-write-contract.ts index c0f57ba9fa..865f0c23e6 100644 --- a/packages/session/session-persistence/tests/live-write-contract.ts +++ b/packages/session/session-persistence/tests/live-write-contract.ts @@ -233,6 +233,10 @@ export function runLiveWritePathContract( const handle = await ctx.sessionPersistence.create(session.header) const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const host = ctx.sessionPersistence as unknown as { persistBatch: (...args: unknown[]) => Promise } + // Materialize under real timers first: the write lock is acquired ahead + // of the first materializing write, and that real I/O must not sit + // inside the fake-timer window below. + await handle.flush() const real = host.persistBatch.bind(host) const persist = vi.spyOn(host, 'persistBatch').mockRejectedValue(new Error('first drain refused')) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 82298d6eaf..3acaf20af1 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -469,6 +469,9 @@ describe('SubagentRuntime.startContinuable', () => { const routeless = await ctx.agentLoop.create(SessionId('routeless-resume'), {}) const started = await ctx.subagents.startContinuable(startSpec(routeless)) await waitNoActivation(ctx, started.childId) + // End the first lifecycle so its write leases release before the fresh + // context re-creates the parent identity and cold-resumes the child. + await ctx.fiber.dispose() const fresh = new Context() await mountAgentLoopTestDependencies(fresh) @@ -481,7 +484,9 @@ describe('SubagentRuntime.startContinuable', () => { await fresh.plugin(TestSessionQuery) await fresh.plugin(SubagentRuntime) await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) - const freshParent = await fresh.agentLoop.create(SessionId('routeless-resume'), {}) + // The disposed lifecycle drained the parent's log durably, so the fresh + // context resumes that identity instead of re-creating it. + const freshParent = (await fresh.agents.resume({ resumeSessionId: SessionId('routeless-resume'), agentOptions: {} })).agent await queuePrompt(fresh, freshParent, started.childId, message('resume routeless')) const resumed = await vi.waitFor(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a305fb42a..fe58f14cd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7210,6 +7210,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + fs-ext: + specifier: 2.1.1 + version: 2.1.1 koffi: specifier: ^3.1.0 version: 3.1.1 @@ -7226,6 +7229,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence + '@types/fs-ext': + specifier: 2.0.3 + version: 2.0.3 packages/session/session-projection: dependencies: @@ -12837,6 +12843,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/fs-ext@2.0.3': + resolution: {integrity: sha512-0j2F+laosJF2NTd2DVheQ5GvXo8ln9L175VwLPfbsppE33iYC+6gn6XlOQS0pGvZm2yrQ32/LRZh0As/7rCs2Q==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -14116,6 +14125,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-ext@2.1.1: + resolution: {integrity: sha512-/TrISPOFhCkbgIRWK9lzscRzwPCu0PqtCcvMc9jsHKBgZGoqA0VzhspVht5Zu8lxaXjIYIBWILHpRotYkCCcQA==} + engines: {node: '>= 8.0.0'} + fs-extra@11.3.1: resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} engines: {node: '>=14.14'} @@ -14963,6 +14976,9 @@ packages: multistream@4.1.0: resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -18497,6 +18513,10 @@ snapshots: '@types/express-serve-static-core': 5.1.3 '@types/serve-static': 2.2.0 + '@types/fs-ext@2.0.3': + dependencies: + '@types/node': 22.20.0 + '@types/geojson@7946.0.16': {} '@types/hast@3.0.5': @@ -19920,6 +19940,10 @@ snapshots: fs-constants@1.0.0: {} + fs-ext@2.1.1: + dependencies: + nan: 2.28.0 + fs-extra@11.3.1: dependencies: graceful-fs: 4.2.11 @@ -20949,6 +20973,8 @@ snapshots: once: 1.4.0 readable-stream: 3.6.2 + nan@2.28.0: {} + nanoid@3.3.12: {} napi-build-utils@2.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 89f38a2eac..e04a413095 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -42,6 +42,9 @@ allowBuilds: node-addon-require-builtin: false # JSONL durability calls MoveFileExW with write-through publication on Windows. koffi: true + # The session write lock is flock(2) / LockFileEx; fs-ext compiles its + # binding with node-gyp at install. + fs-ext: true # The Python runtime deploy includes the reviewed workspace postinstall that # restores the executable bit on node-pty's macOS spawn helper. '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 5a03ec41c1..abbd3c6b3b 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -74,6 +74,8 @@ const OVERRIDES: Record = { '@modelcontextprotocol/server-filesystem': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' }, // No repository field in the published manifest. 'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' }, + // No `license` field in the published manifest; the tarball's LICENSE.txt is the MIT text. + 'fs-ext': { license: 'MIT' }, } /** diff --git a/vitest.config.ts b/vitest.config.ts index 380eb6ad6d..c409a80975 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -93,7 +93,15 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' // never measures child processes. Its behavior is pinned end-to-end by // tests/runner.spec.ts, which spawns the real entry through tsx. const windowsRunnerCoverageExclusions = process.platform === 'win32' - ? ['packages/sandbox/sandbox-windows-acl/src/runner.ts'] + ? [ + 'packages/sandbox/sandbox-windows-acl/src/runner.ts', + // The session write lock's POSIX face (fs-ext flock plus inode + // verification) executes only off-Windows: the Linux lanes hold its + // per-file 100%, while the Windows branch is unit-pinned by + // win32.spec's injected bindings and exercised natively by every + // Windows suite through the real backend. + 'packages/session/session-persistence-jsonl/src/lease.ts', + ] : [] // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh From 46d20f8bee8d842d77f4a3a771d1c83900546bb5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 14:31:54 +0800 Subject: [PATCH 2/5] feat(web): rank skill candidates with the shared fuzzy name ranker --- ...eb-slash-command-fuzzy-discovery.i18n.yaml | 4 +- ...08-04-web-slash-command-fuzzy-discovery.md | 10 ++- ...04-web-slash-command-fuzzy-discovery.zh.md | 10 ++- .../menu-fuzzy.expected.md | 3 + apps/web/tests/skill-invocation-policy.e2e.ts | 13 ++- .../client/ui-commands/src/client/service.ts | 73 ++--------------- .../ui-commands/tests/service.client.spec.ts | 17 +--- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 1 + .../client/ui-primitives/src/rank-by-name.ts | 79 +++++++++++++++++++ .../tests/rank-by-name.client.spec.ts | 41 ++++++++++ packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 6 +- .../tests/browser-plugin.client.spec.ts | 9 ++- 18 files changed, 178 insertions(+), 104 deletions(-) create mode 100644 apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md create mode 100644 packages/client/ui-primitives/src/rank-by-name.ts create mode 100644 packages/client/ui-primitives/tests/rank-by-name.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml index 1e3ada6894..51848d9768 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md -2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238 -2026-08-04-web-slash-command-fuzzy-discovery.zh.md: a96f9c984e32dd777950b9f9d8594b3a9c8b7c17 +2026-08-04-web-slash-command-fuzzy-discovery.md: 17ba1a1cba4876140bada74e65ba1b10ed5fdd76 +2026-08-04-web-slash-command-fuzzy-discovery.zh.md: fe23dcb5d3c14a492a5882b5bad4c84e256ef064 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md index 8d7fe88f8d..17ba1a1cba 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md @@ -6,13 +6,13 @@ English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md) ## Problem -The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. +The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. The skill source of the same `/` menu later kept a case-sensitive prefix filter, so the two groups of one menu answered the same keystrokes differently. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. ## Decision -The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. +The `/` menu's command and skill sources fuzzy-match the typed query against candidate names as a case-insensitive ordered subsequence through one ranker, `rankByName` in ui-primitives, the narrow static owner for shared browser code ([client rules](../../../../packages/client/AGENTS.md)). Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host catalog and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. -The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token. +The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, the skill source still lands the literal `/name ` text the host resolves exactly, and space and Enter adjudication continue to require an exact command token. ## Alternatives considered @@ -22,6 +22,8 @@ The scorer uses dynamic programming in `O(query length × name length)` time and **Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product. +**Export the ranker from the command plugin or the trigger pipeline.** Rejected because a feature plugin exports no values beyond what cordis loading needs and never runtime-imports another feature plugin; peer agent products that share one matcher between commands and skills (Claude Code, Pi, Kimi Code) keep it in a shared library for the same reason. + ## Consequences -Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged. +Users can discover a command or a skill from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. ui-primitives tests pin each ranking factor and stable ties, the command and skill sources pin that they rank through the shared ranker, and the assembled Web goldens pin `/cpt` resolving to `/compact` and a subsequence query resolving to one skill. Exact execution semantics are unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md index a96f9c984e..fe23dcb5d3 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 +Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。同一 `/` 菜单的 skill source 后来仍保留区分大小写的前缀过滤,同一菜单的两个分组对同样的按键给出不同答案。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 ## 决策 -`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 +`/` 菜单的命令 source 与 skill source 将键入的查询作为不区分大小写的有序子序列,与候选名进行模糊匹配,二者共用一个排序器:ui-primitives 中的 `rankByName`,即共享浏览器代码的窄静态归属方([client 规则](../../../../packages/client/AGENTS.md))。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 -评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。 +评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,skill source 仍落下由宿主精确解析的字面 `/name ` 文本,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。 ## 考虑过的替代方案 @@ -22,6 +22,8 @@ Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字 **使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。 +**从命令插件或触发管线导出排序器。** 否决,因为特性插件除 cordis 加载所需之外不导出任何值,也绝不运行时导入另一个特性插件;在命令与 skill 之间共用一个匹配器的同行产品(Claude Code、Pi、Kimi Code)出于同样的原因把它放在共享库中。 + ## 后果 -用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。 +用户可以凭按顺序记得的字母发现命令或 skill;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。ui-primitives 的测试固定各项排名因素以及同分时的稳定顺序,命令 source 与 skill source 的测试固定二者经共享排序器排名,组装后的 Web golden 固定 `/cpt` 解析为 `/compact`、以及一个子序列查询解析为唯一 skill 的行为。精确执行语义保持不变。 diff --git a/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md b/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md new file mode 100644 index 0000000000..798d9510c4 --- /dev/null +++ b/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md @@ -0,0 +1,3 @@ +- listbox "Trigger suggestions": + - text: Skills + - option "policy-user-only user-only · Available only to user invocation" [selected] diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 2de45d3f9b..73d928b9da 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -18,10 +18,11 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-invocation-policy', import.meta.url)) const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const FUZZY_MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu-fuzzy.expected.md') const MODE = webSnapshotMode() interface SeedSkill { @@ -111,8 +112,16 @@ describe('web e2e: skill invocation policy through the real host', () => { const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) + + // Discovery needs no prefix: an in-order subsequence of one skill name + // ranks that skill alone, through the ranker the command group uses. + await writeComposerDraft(page, input, '/plcyusr') + await expect.poll(() => menu.getByRole('option').count(), { timeout: 10_000 }).toBe(1) + expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(1) + const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md']) }) }) diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 4a606fe607..055108e954 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -2,8 +2,9 @@ * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the * session-keyed directory, the client-contribution registry, and the * per-session popupSelect controllers. Candidate synthesis merges the host - * catalog with contributions by availability, then fuzzy query/position - * filtering; a host/contribution name collision fails loud. Every execute + * catalog with contributions by availability, then position filtering and + * the `/` menu's shared name ranking (ui-primitives `rankByName`); a + * host/contribution name collision fails loud. Every execute * addresses the session's agent by sessionId — sessions are always * agent-backed. */ @@ -17,6 +18,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, @@ -56,69 +58,6 @@ interface LiveState { readonly popups: Map> } -/** One fuzzy match with its stable source position. */ -interface RankedCandidate { - readonly candidate: InputTriggerCandidate - readonly index: number - readonly prefix: boolean - readonly score: number -} - -/** Extra weight for command-name starts and separator boundaries. */ -function boundaryBonus(name: string, index: number): number { - return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 -} - -/** - * Score the strongest ordered-subsequence alignment in O(name × query). - * Boundary and adjacent matches earn weight; skipped and leading characters - * cost weight. - */ -function fuzzyScore(name: string, query: string): number | undefined { - if (query === '') return 0 - if (query.length > name.length) return undefined - const noMatch = Number.NEGATIVE_INFINITY - let previous = Array(name.length).fill(noMatch) - for (let index = 0; index < name.length; index++) { - if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index - } - for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { - const current = Array(name.length).fill(noMatch) - let bestGapped = noMatch - for (let index = 0; index < name.length; index++) { - const gappedIndex = index - 2 - if (gappedIndex >= 0) { - const prior = previous[gappedIndex] ?? noMatch - if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex) - } - if (name.charAt(index) !== query.charAt(queryIndex)) continue - const bonus = 1 + boundaryBonus(name, index) - const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch - if (adjacent !== noMatch) current[index] = adjacent + bonus + 4 - if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index) - } - previous = current - } - let best = noMatch - for (const score of previous) best = Math.max(best, score) - return best === noMatch ? undefined : best -} - -/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */ -function fuzzyCandidates(candidates: readonly InputTriggerCandidate[], rawQuery: string): readonly InputTriggerCandidate[] { - const query = rawQuery.toLowerCase() - if (query === '') return candidates - const ranked: RankedCandidate[] = [] - candidates.forEach((candidate, index) => { - const name = candidate.name.toLowerCase() - const score = fuzzyScore(name, query) - if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score }) - }) - ranked.sort((left, right) => - Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) - return ranked.map(match => match.candidate) -} - /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandUiRuntime extends Service implements CommandUiContract { static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands'] @@ -246,7 +185,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } } - /** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */ + /** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */ private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { const list = await this.directory.ensureReady(session.sessionId, req.signal) const rows: InputTriggerCandidate[] = [] @@ -262,7 +201,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } rows.push({ name: contribution.name, description: contribution.description }) } - return fuzzyCandidates( + return rankByName( rows.filter(c => req.position === 'leading' || c.hint === undefined), req.query, ) diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index ae8ef7a884..fa143b0b24 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -209,24 +209,15 @@ describe('candidates', () => { expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }]) }) - it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => { + it('ranks rows through the shared name ranker: prefixes first, then alignment, then source order', async () => { const commands: CommandDescriptor[] = [ - { name: 'q-xylophone', description: '' }, - { name: 'qx-long', description: '' }, - { name: 'fabulous', description: '' }, - { name: 'foo-bar', description: '' }, - { name: 'zuv', description: '' }, - { name: 'zu1v', description: '' }, - { name: 'yu1v', description: '' }, - { name: 'zu12v', description: '' }, + { name: 'z_a_b', description: '' }, + { name: 'abc', description: '' }, ] const { source } = await bench({ commands: () => Promise.resolve({ commands }) }) const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) - await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone']) - await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous']) - await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + await expect(names('AB')).resolves.toEqual(['abc', 'z_a_b']) await expect(names('zzz')).resolves.toEqual([]) - await expect(names('query-longer-than-every-name')).resolves.toEqual([]) }) it('catalogs are per session: another session pulls its own key', async () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9b7c3a7285..33404cc14a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 78cb6caec665687feb1c18c65e1afe47c6e01ea1 -README.zh.md: 1450de83f88e4e33be4616ebd1f70588dd19e8d2 +README.md: c280f0b179b115f417514b40b21179eaac77ad79 +README.zh.md: e9cea4b31bdbefaa9cc076d5b6866d4e870a70d9 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 78cb6caec6..c280f0b179 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -29,7 +29,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Controls and icons -`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. +`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 1450de83f8..e9cea4b31b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -29,7 +29,7 @@ kind: "package-library" ### 控件与图标 -`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 +`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index c5f88e5eee..485ac9fc26 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -34,6 +34,7 @@ export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' export { writeClipboard } from './clipboard.ts' export { relativeTime } from './relative-time.ts' +export { rankByName } from './rank-by-name.ts' export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' diff --git a/packages/client/ui-primitives/src/rank-by-name.ts b/packages/client/ui-primitives/src/rank-by-name.ts new file mode 100644 index 0000000000..97d6903728 --- /dev/null +++ b/packages/client/ui-primitives/src/rank-by-name.ts @@ -0,0 +1,79 @@ +/** + * Shared ranking for `/` menu candidates: the query must be a + * case-insensitive ordered subsequence of the candidate name. Prefix hits + * rank first, then the strongest alignment score, then the source order of + * the input. Decision record: + * .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md + */ + +/** One match with its stable source position. */ +interface Ranked { + readonly item: T + readonly index: number + readonly prefix: boolean + readonly score: number +} + +/** Extra weight for name starts and separator boundaries. */ +function boundaryBonus(name: string, index: number): number { + return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 +} + +/** + * Score the strongest ordered-subsequence alignment in O(name × query). + * Boundary and adjacent matches earn weight; skipped and leading characters + * cost weight. Undefined when the query is not a subsequence of the name. + */ +function alignmentScore(name: string, query: string): number | undefined { + if (query.length > name.length) return undefined + const noMatch = Number.NEGATIVE_INFINITY + let previous = Array(name.length).fill(noMatch) + for (let index = 0; index < name.length; index++) { + if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index + } + for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { + const current = Array(name.length).fill(noMatch) + // Sweep the previous row once: `left` is its score one character back + // (the adjacent continuation), `leftLeft` two back (the earliest gapped one). + let left = noMatch + let leftLeft = noMatch + let bestGapped = noMatch + for (const [index, prior] of previous.entries()) { + if (leftLeft !== noMatch) bestGapped = Math.max(bestGapped, leftLeft + index - 2) + if (name.charAt(index) === query.charAt(queryIndex)) { + const bonus = 1 + boundaryBonus(name, index) + let score = noMatch + if (left !== noMatch) score = left + bonus + 4 + if (bestGapped !== noMatch) score = Math.max(score, bestGapped + bonus + 1 - index) + current[index] = score + } + leftLeft = left + left = prior + } + previous = current + } + let best = noMatch + for (const score of previous) best = Math.max(best, score) + return best === noMatch ? undefined : best +} + +/** + * Rank named items by a menu query. + * @param items - candidates in source order (a host catalog, then client contributions). + * @param rawQuery - the text typed after the trigger, matched case-insensitively. + * @returns the matching items: prefix hits first, then by alignment score, + * then in source order. The input list itself for an empty query. + */ +export function rankByName(items: readonly T[], rawQuery: string): readonly T[] { + const query = rawQuery.toLowerCase() + if (query === '') return items + const ranked: Ranked[] = [] + items.forEach((item, index) => { + const name = item.name.toLowerCase() + const score = alignmentScore(name, query) + if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score }) + }) + ranked.sort((left, right) => + Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) + return ranked.map(match => match.item) +} diff --git a/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts new file mode 100644 index 0000000000..89ed5c7733 --- /dev/null +++ b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts @@ -0,0 +1,41 @@ +/** + * Shared `/` menu ranker: case-insensitive ordered-subsequence matching, + * prefix hits first, alignment score next, source order for ties. + */ +import { describe, expect, it } from 'vitest' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' + +const named = (...names: string[]) => names.map(name => ({ name })) +const names = (items: readonly { name: string }[]) => items.map(item => item.name) + +describe('rankByName', () => { + it('returns the input list itself for an empty query', () => { + const items = named('b', 'a') + expect(rankByName(items, '')).toBe(items) + }) + + it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', () => { + const items = named('q-xylophone', 'qx-long', 'fabulous', 'foo-bar', 'zuv', 'zu1v', 'yu1v', 'zu12v') + expect(names(rankByName(items, 'QX'))).toEqual(['qx-long', 'q-xylophone']) + expect(names(rankByName(items, 'fb'))).toEqual(['foo-bar', 'fabulous']) + expect(names(rankByName(items, 'uv'))).toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + expect(names(rankByName(items, 'zzz'))).toEqual([]) + expect(names(rankByName(items, 'query-longer-than-every-name'))).toEqual([]) + }) + + it('a prefix hit outranks a stronger non-prefix alignment', () => { + // 'z_a_b' aligns both characters on separator boundaries and outscores + // every non-prefix rival; a name that starts with the query still wins. + expect(names(rankByName(named('z_a_b', 'xabc'), 'ab'))).toEqual(['z_a_b', 'xabc']) + expect(names(rankByName(named('z_a_b', 'abc'), 'ab'))).toEqual(['abc', 'z_a_b']) + }) + + it('takes the stronger of an adjacent and a gapped alignment for the same character', () => { + expect(names(rankByName(named('aab', 'ab'), 'ab'))).toEqual(['ab', 'aab']) + }) + + it('returns the ranked items with their payload intact', () => { + const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }] + expect(rankByName(items, 'pl')).toEqual([{ name: 'plan', description: 'p' }]) + }) +}) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 001cfc22ab..fbda419e61 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: 6f5bd109a66b061db3ab5069b15bb4f101039e73 -README.zh.md: 61941ef1b5966d9155d724873b25e03d38d214e1 +README.md: a98194f7e590eb8a68bd329495896e9f2df1b506 +README.zh.md: c4f444f1c749f0697e8883166a0f4ea8ca2009ba diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 6f5bd109a6..a98194f7e5 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -29,7 +29,7 @@ Type `/` in the composer and pick a skill from the suggestions, or type `/name` ### What the source offers -Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results filter by `startsWith(query)`. A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. +Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results rank through the `/` menu's shared name ranker, `rankByName` from ui-primitives: the query matches a case-insensitive ordered subsequence of the skill name, prefix hits rank first, and ties keep the host order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. ### The skill tool row diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 61941ef1b5..c4f444f1c7 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### source 提供什么 -普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果按 `startsWith(query)` 过滤。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 +普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果经 `/` 菜单共享的名字排序器(ui-primitives 的 `rankByName`)排名:查询作为不区分大小写的有序子序列匹配 skill 名,前缀命中排最前,同分保持宿主顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 ### skill 工具行 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 44712b7286..e15a748ca1 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -35,6 +35,7 @@ import type { SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). @@ -142,8 +143,9 @@ export function apply(ctx: ClientContext): void { const skills = await fetchCatalog(session.sessionId) // Superseded keystroke: the shared fetch stays warm, this caller yields. if (signal.aborted) return [] - return skills - .filter(skill => skill.name.startsWith(query)) + // The same ranking as the command group of this menu: case-insensitive + // ordered subsequence, prefix hits first. + return rankByName(skills, query) .map(skill => ({ name: skill.name, // The user-only marker rides the description (the menu's only diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 1c812e460e..82ca97029c 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -5,7 +5,7 @@ * the source behavior contract driven directly on the captured source with * real ClientSessionContext projections — sessionId addressing, the * session-keyed catalog cache (single-flight per key, scope-birth warm - * prewarm, connection/reset clear), startsWith filtering, RPC-failure + * prewarm, connection/reset clear), shared fuzzy name ranking, RPC-failure * rejection, pick → plain-text outcome (the plain-text-reference decision: * .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md), * the synchronous @@ -167,7 +167,7 @@ describe('apply', () => { }) describe('candidates: sessionId addressing', () => { - it('lists via {sessionId} and filters by startsWith(query)', async () => { + it('lists via {sessionId} and ranks case-insensitive subsequence matches with prefixes first', async () => { const { list, payloads } = countingList() const { source } = await bench(list) const items = await source.candidates(proj('s1'), req('co')) @@ -177,6 +177,11 @@ describe('candidates: sessionId addressing', () => { { name: 'commit-helper', description: 'commit flow' }, { name: 'code-review', description: 'review flow' }, ]) + const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) + // 'de' prefixes deploy and is a subsequence of code-review: the prefix ranks first. + await expect(names('de')).resolves.toEqual(['deploy', 'code-review']) + await expect(names('REV')).resolves.toEqual(['code-review']) + await expect(names('zzz')).resolves.toEqual([]) }) it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => { From d0da8ccf40c16c411d062a4046975ac9cdc486f7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 14:32:38 +0800 Subject: [PATCH 3/5] fix(web): decorate slash tokens in bubbles from logged skill and command facts --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 4 +- ...web-input-machine-and-slash-pipeline.zh.md | 4 +- .../ui-chat/src/client/chat/MessageItem.tsx | 7 +- .../chat-snapshot-builder.ts | 85 ++++++++++++++++++- .../conversation-nodes/event-projection.ts | 11 +++ .../src/client/conversation-nodes/message.ts | 4 + .../tests/chat-branch-tails.client.spec.tsx | 27 +++++- ...nversation-node-definitions.client.spec.ts | 52 ++++++++++++ .../src/client/input/decorations.ts | 11 ++- .../tests/submit-machine.client.spec.ts | 8 ++ packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- .../client/GoalCommandInputView.module.css | 8 +- .../src/client/GoalCommandInputView.tsx | 13 ++- .../ui-goal/src/client/goal-command-input.ts | 5 +- .../goal-command-input-styles.client.spec.ts | 31 +++++++ .../tests/goal-command-input.client.spec.tsx | 3 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 1 - .../src/markdown/MessageText.module.css | 9 -- .../src/markdown/MessageText.tsx | 7 -- .../ui-primitives/src/user-text.module.css | 8 ++ .../client/ui-primitives/src/user-text.tsx | 38 +++++++-- .../tests/markdown.client.spec.tsx | 9 -- .../tests/user-text-styles.client.spec.ts | 9 ++ .../tests/user-text.client.spec.tsx | 38 ++++++++- 30 files changed, 342 insertions(+), 70 deletions(-) create mode 100644 packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts delete mode 100644 packages/client/ui-primitives/src/markdown/MessageText.module.css delete mode 100644 packages/client/ui-primitives/src/markdown/MessageText.tsx 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 28ab0da3dd..89baea729d 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 @@ -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-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 200761cc9e648eea80bdae9d7b363246c816e5d1 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c +2026-07-25-web-input-machine-and-slash-pipeline.md: 500f0acee97f225b0e1a05107f12a5c3ee814bfe +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: cfe0781b945e4570f8f8ca5a6b71a95a2eafadcf 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 200761cc9e..500f0acee9 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 @@ -67,8 +67,8 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - 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 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 becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. -- Sending is the literal text (no more `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` tag + plain-text tokens). +- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace, the draft end, or trailing sentence punctuation — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. +- Sending is the literal text (no more `` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape. - Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, 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 673d0ee4bd..cfe0781b94 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 @@ -67,8 +67,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。 -- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 -- 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 token)。 +- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白、draft 末尾或句尾标点——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 +- 发送即原文(不再 `` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 ### 每会话供数贡献与键盘私面 diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 07e9fb32a0..562d2235d1 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -148,7 +148,7 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], previewImages, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -160,6 +160,8 @@ function UserStyleBubble({ echo?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] + /** Skill names the step's `skill-invocation` injections loaded for this message. */ + skillNames?: readonly string[] /** Local submission-echo previews replacing the content-derived image group. */ previewImages?: readonly MessageImageSource[] t: ChatViewSlotProps['t'] @@ -177,7 +179,7 @@ function UserStyleBubble({
{renderMessageImages({ images, align: 'end' })} {showBubble &&
- {projectUserText(text, referenceLabels)} + {projectUserText(text, referenceLabels, skillNames)} {rest.map((block, i) => )}
} {referenceLabels.length > 0 && ( @@ -279,6 +281,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({ content={data.content} renderMessageImages={renderMessageImages} {...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }} + {...data.skillNames === undefined ? {} : { skillNames: data.skillNames }} t={t} actions={text => ( 0)) return node + const data: Record = { ...candidate.data } + if (names.length === 0) delete data.skillNames + else data.skillNames = names + return { ...candidate, data } +} + +/** + * Skill names each direct message's step loaded, keyed by message Node key. + * + * A step's `skill-invocation` injections follow the direct messages the host + * scanned for `/name` gestures and precede the step's first non-message Node + * (the assistant step, a Turn error, a command); every Node of another kind + * therefore closes the batch. Names attach to every direct message of the + * batch: the bubble decorates only the tokens its own text carries. + * @param nodes - every materialized Chat Node, in any order. + * @returns the loaded skill names per direct message key; absent for none. + */ +function skillNamesByMessage(nodes: readonly ChatConversationViewNode[]): Map { + const ordered = [...nodes].sort((left, right) => left.anchorSeq - right.anchorSeq) + const result = new Map() + let batchMessages: string[] = [] + let batchNames: string[] = [] + const flush = (): void => { + if (batchNames.length > 0) for (const key of batchMessages) result.set(key, batchNames) + batchMessages = [] + batchNames = [] + } + for (const node of ordered) { + const candidate = node as ChatNode + if (candidate.kind === 'user' || candidate.kind === 'steering') { + batchMessages.push(node.key) + continue + } + if (candidate.kind !== 'context') { + flush() + continue + } + const name = skillInvocationName(candidate.data.source) + if (name !== null && !batchNames.includes(name)) batchNames.push(name) + } + flush() + return result +} + +/** Attaches each direct message's step-loaded skill names to its Node. */ +class SkillNameProjector { + replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] { + const names = skillNamesByMessage(nodes) + return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS)) + } + + apply( + upserts: readonly ChatConversationViewNode[], + store: ChatNodeStore, + ): readonly ChatConversationViewNode[] { + const byKey = new Map(upserts.map(node => [node.key, node])) + const all = new Map(store.values().map(node => [node.key, node])) + for (const [key, node] of byKey) all.set(key, node) + const names = skillNamesByMessage([...all.values()]) + for (const [key, node] of all) { + const candidate = node as ChatNode + if (candidate.kind !== 'user' && candidate.kind !== 'steering') continue + const next = withSkillNames(node, names.get(key) ?? EMPTY_KEYS) + if (next !== node || byKey.has(key)) byKey.set(key, next) + } + return [...byKey.values()] + } +} + interface LegacyContribution { readonly anchorSeq: number readonly nodes: readonly ConversationNode[] @@ -756,6 +834,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.locations.rebuild(this.order, this.store) @@ -785,7 +864,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder() let structural = false const contentOnly: ChatConversationViewNode[] = [] diff --git a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts index 0d909cdcdd..1695a467fe 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts @@ -88,6 +88,17 @@ export function sessionRecallLabels(source: unknown): string[] { return collect(record, 'references', 'label') } +/** + * Read the skill name a durable skill-invocation injection loaded. + * @param source - Logged `user/message` source. + * @returns The skill name, or null for every other source. + */ +export function skillInvocationName(source: unknown): string | null { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'skill-invocation') return null + return readString(record, 'name') +} + /** * Classify finalized Assistant content for Chat rendering. * @param content - Core content blocks. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 1927a468c5..b7806477a6 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -9,11 +9,15 @@ import { contextForm, contextProvenance } from './event-projection.ts' interface ReferencedUserMessageNode extends UserMessageNode { /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] + /** Skill names the same step's `skill-invocation` injections loaded. */ + readonly skillNames?: readonly string[] } interface ReferencedSteeringMessageNode extends SteeringMessageNode { /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] + /** Skill names the same step's `skill-invocation` injections loaded. */ + readonly skillNames?: readonly string[] } type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 7bcd9373b2..4c817738d7 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -48,10 +48,11 @@ interface MessageItemProps { readonly node: ConversationNode readonly t: ChatNodeViewProps['t'] readonly referenceLabels?: readonly string[] + readonly skillNames?: readonly string[] } /** Legacy-node fixture adapter for the independently registered renderers. */ -function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) { +function MessageItem({ node, t: translate, referenceLabels, skillNames }: MessageItemProps) { const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind const viewNode: ChatConversationViewNode = { key: `fixture:${node.kind}:${node.seq}`, @@ -63,8 +64,12 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) visibility: 'visible', data: node.kind === 'model-retry' ? { attempts: [node], current: node } - : (node.kind === 'user' || node.kind === 'steering') && referenceLabels !== undefined - ? { ...node, referenceLabels } + : (node.kind === 'user' || node.kind === 'steering') && (referenceLabels !== undefined || skillNames !== undefined) + ? { + ...node, + ...(referenceLabels === undefined ? {} : { referenceLabels }), + ...(skillNames === undefined ? {} : { skillNames }), + } : node, } const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps @@ -141,6 +146,22 @@ describe('MessageItem arms', () => { expect(view.container.textContent).toContain('README.md, please.') }) + it('decorates a slash token as a skill chip only when the step resolved that skill', () => { + const message = { + kind: 'user' as const, + seq: 1, + time: 1_000, + content: [{ type: 'text', text: '/123 then /demo-skill go' }] as never, + source: null, + } + const plain = render() + expect(plain.container.querySelectorAll('[data-ref-chip]').length).toBe(0) + const resolved = render() + const chips = [...resolved.container.querySelectorAll('[data-ref-chip="skill"]')] + expect(chips.map(chip => chip.textContent)).toEqual(['/demo-skill']) + expect(resolved.container.textContent).toContain('/123 then ') + }) + it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index f401455a32..50bcb83297 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1766,6 +1766,58 @@ describe('built-in conversation node Definitions', () => { }) }) + it('associates a direct message with the skill invocations injected for its step', () => { + const skillInvocation = (id: string) => ({ + ...textMessage(id, 'instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }) + const instructions = (id: string) => ({ + ...textMessage(id, 'workspace rules'), + source: { kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }] }, + }) + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'user/message', instructions('rules-1'), { surfaceOp: 'append' }), + at(5, 'user/message', skillInvocation('skill-body'), { surfaceOp: 'append' }), + at(6, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('answer-1', 'done'), + }, { surfaceOp: 'append' }), + at(7, 'step/end', { turn: 1, step: 1 }), + at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(9, 'turn/start', { turn: 2 }), + at(10, 'user/message', textMessage('later', '/demo-skill again?'), { surfaceOp: 'append' }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(12, 'user/message', instructions('rules-2'), { surfaceOp: 'append' }), + ]) + + const users = [...snapshot(value).nodes.values()].filter(candidate => candidate.kind === 'user') + expect(users).toHaveLength(2) + expect(users[0]?.data).toMatchObject({ skillNames: ['demo-skill'] }) + expect(users[1]?.data).not.toHaveProperty('skillNames') + }) + + it('updates an already published direct node when its skill injection arrives', () => { + const value = assembler([ + at(1, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }), + ]) + const before = node(snapshot(value), 'user') + expect(before?.data).not.toHaveProperty('skillNames') + + value.append(at(2, 'user/message', { + ...textMessage('skill-body', 'instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }, { surfaceOp: 'append' })) + value.flush() + + const after = node(snapshot(value), 'user') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ skillNames: ['demo-skill'] }) + }) + it('keeps replacement copies out of Chat business nodes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index e20a006dac..64a19c2828 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -23,12 +23,20 @@ export interface TextRefRange { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g +/** + * What may follow a `/name` token: whitespace or the draft end, optionally + * after trailing sentence punctuation. The host skill gesture + * (`dsh-tool-skill`) is whitespace-bounded, so `/nfs-hg/xxx` or `/plan.md` + * is a path, never a reference. + */ +const SLASH_TOKEN_END_RE = /^[.,;:!?,。;:!?]*(?:\s|$)/ /** * Scan the draft for plain-text reference tokens against the hot lexicons. * Word-boundary discipline: the trigger must sit at the draft * start or after whitespace ('x/name' never matches); the name must be an - * exact lexicon member. + * exact lexicon member; a `/name` token must end at whitespace, the draft + * end, or trailing sentence punctuation ('/name/x' is a path). * @param draft - draft text. * @param lexicon - per-trigger name lists (a missing trigger scans nothing). * @returns matched ranges in draft order. @@ -44,6 +52,7 @@ export function scanTextRefs( while ((m = TEXT_REF_RE.exec(draft)) !== null) { const trigger = m[2] as '/' | '@' const name = m[3] ?? '' + if (trigger === '/' && !SLASH_TOKEN_END_RE.test(draft.slice(m.index + m[0].length))) continue if (lexicon.get(trigger)?.includes(name)) { const start = m.index + (m[1]?.length ?? 0) out.push({ start, end: start + 1 + name.length, trigger }) diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index af1e72f47e..faf1f4e33f 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -347,6 +347,14 @@ describe('decorations: scanTextRefs', () => { expect(scanTextRefs('/research @goal', lexicon)).toEqual([]) }) + it('a "/" token continued by a path never matches, even when the name is on the lexicon', () => { + expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([]) + }) + + it('a "/" token may end at trailing punctuation before whitespace', () => { + expect(scanTextRefs('/goal。 then', lexicon)).toEqual([{ start: 0, end: 5, trigger: '/' }]) + }) + it('word boundary: a trigger glued to text never matches', () => { expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([]) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 096dec8b0d..1290b0416d 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: 1a2c65759f5983fbece83f538ee9ee09902dd020 -README.zh.md: feebeaff0b5e7e9258f15637d0a78a897e0f24c1 +README.md: 8556575b00e1d3073f251d83f3dee0bafb4c8969 +README.zh.md: 22d0417bc2d37719150ec46aad849ad2bce8b460 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 1a2c65759f..8556575b00 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s ### The command-input bubble -Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. +Each durable `/goal` run projects as a right-aligned user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row; the leading `/goal` token renders as a command reference chip in the code face through ui-primitives `projectUserText`, and the objective stays plain body text. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. ### Failures diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index feebeaff0b..22d0417bc2 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 指令输入气泡 -每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 +每条持久的 `/goal` 运行都投影为一个右对齐的用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前;开头的 `/goal` token 经 ui-primitives 的 `projectUserText` 以等宽代码字体渲染为指令引用 chip,目标文本保持正文字体。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 ### 失败 diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css index f403730eff..42fff6430e 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css @@ -23,10 +23,10 @@ border-radius: 22px; background: var(--dsw-specific-bubble); color: var(--dsw-alias-label-primary); - font: var(--dsw-font-markdown-code); - /* The command echo is a user message, not dense code-in-prose: keep the - code family from the token but read at the body size, riding the - Settings font-size axis like the user bubble (14/22 at the default). */ + /* The command echo is a user message: it inherits the body face like the + user bubble and rides the same Settings font-size axis (14/22 at the + default); the `/goal` command chip, set in the code face by the shared + projection, is what marks the line as a command. */ font-size: var(--dsh-content-font-size, 14px); line-height: calc(22px + var(--dsh-content-font-delta, 0px)); white-space: pre-wrap; diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx index 6d2345042b..43f3dc30b2 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx @@ -1,14 +1,19 @@ import { memo } from 'react' -import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { GoalCommandInputData } from './goal-command-input.ts' +import { GOAL_COMMAND, type GoalCommandInputData } from './goal-command-input.ts' import css from './GoalCommandInputView.module.css' type GoalCommandInputViewProps = PropsRuntime<'conversation.chat.node', 'command-input'> & PropsLocale<'goal'> -/** Right-aligned `/goal` input bubble without ordinary message actions. */ +/** + * Right-aligned `/goal` input bubble without ordinary message actions. The + * echoed line decorates its leading `/goal` token as a command chip — the run + * this Node projects is the fact that the token was a command — and keeps + * the objective as plain text. + */ export const GoalCommandInputView = memo(function GoalCommandInputView({ node, t, }: GoalCommandInputViewProps) { @@ -22,7 +27,7 @@ export const GoalCommandInputView = memo(function GoalCommandInputView({ >
- + {projectUserText(data.text, [], [GOAL_COMMAND], 'command')}
diff --git a/packages/client/ui-goal/src/client/goal-command-input.ts b/packages/client/ui-goal/src/client/goal-command-input.ts index 526dc0958d..742959a349 100644 --- a/packages/client/ui-goal/src/client/goal-command-input.ts +++ b/packages/client/ui-goal/src/client/goal-command-input.ts @@ -5,6 +5,9 @@ import type { ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' +/** The command name whose runs this projection owns. */ +export const GOAL_COMMAND = 'goal' + /** Goal-owned human command input projected independently of model messages. */ export interface GoalCommandInputData { readonly commandId: CommandId @@ -36,7 +39,7 @@ export function goalCommandText(event: SessionEvent<'command/run'>): string { export const goalCommandInputDefinition: ConversationNodeDefinition = { kind: 'goal-command-input', target: 'chat', - match: event => event.type === 'command/run' && event.data.name === 'goal' + match: event => event.type === 'command/run' && event.data.name === GOAL_COMMAND ? { id: String(event.data.commandId), role: 'start' } : null, start: (_context, match) => { diff --git a/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts b/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts new file mode 100644 index 0000000000..41b8fcaa4f --- /dev/null +++ b/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts @@ -0,0 +1,31 @@ +/** + * The command-input bubble's typography as CSS text. jsdom has no layout, so + * this reads the declarations that make the bubble share the user bubble's + * face and size axis: the shared projection sets the `/goal` chip in the code + * face, so the bubble itself must not pin a different family. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync( + fileURLToPath(new URL('../src/client/GoalCommandInputView.module.css', import.meta.url)), + 'utf8', +).replace(/\/\*[\s\S]*?\*\//g, ' ') + +function declarations(selector: string): string[] { + const rule = new RegExp(`(?:^|\\})\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(css) + if (rule === null) throw new Error(`GoalCommandInputView.module.css has no \`${selector}\` rule`) + return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +describe('GoalCommandInputView.module.css typography', () => { + it('types the command bubble like the user bubble: no family override, same size axis', () => { + const bubble = declarations('.bubble') + expect(bubble.some(declaration => /^font(-family)?:/.test(declaration))).toBe(false) + expect(bubble).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size, 14px)', + 'line-height: calc(22px + var(--dsh-content-font-delta, 0px))', + ])) + }) +}) diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index d149cd7f4f..6f12c5e456 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -133,5 +133,8 @@ describe('goal command input projection', () => { expect(bubble.textContent).toBe('/goal ship it') expect(within(bubble).queryByRole('button')).toBeNull() + // The executed command token reads as a reference chip; the objective stays plain text. + const chips = [...bubble.querySelectorAll('[data-ref-chip]')] + expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']]) }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 33404cc14a..0036b684de 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: c280f0b179b115f417514b40b21179eaac77ad79 -README.zh.md: e9cea4b31bdbefaa9cc076d5b6866d4e870a70d9 +README.md: 758132bc9c193594a9407a105820633c75e58880 +README.zh.md: 8b3fffa9e73464d526c3a2f11b212797c2707c69 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c280f0b179..758132bc9c 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -33,7 +33,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Rendering agent output -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content. +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. ### Localizing copy diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e9cea4b31b..8b3fffa9e7 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -33,7 +33,7 @@ kind: "package-library" ### 渲染 agent 输出 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 ### 本地化文案 diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 485ac9fc26..44b9e8e2c5 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -58,7 +58,6 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx' -export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' export * from './icons/index.tsx' diff --git a/packages/client/ui-primitives/src/markdown/MessageText.module.css b/packages/client/ui-primitives/src/markdown/MessageText.module.css deleted file mode 100644 index c6a9c9b245..0000000000 --- a/packages/client/ui-primitives/src/markdown/MessageText.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.text { - white-space: pre-wrap; - word-break: break-word; - /* Font metrics inherit from the consumer's container (bubble 16/24, - assistant flow 16/28) — a generic text primitive must not pin its own - size, or every consumer's line grid breaks (44px bubble spec regression). */ - font-size: inherit; - line-height: inherit; -} diff --git a/packages/client/ui-primitives/src/markdown/MessageText.tsx b/packages/client/ui-primitives/src/markdown/MessageText.tsx deleted file mode 100644 index cafe9ab3c0..0000000000 --- a/packages/client/ui-primitives/src/markdown/MessageText.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText. - -import css from './MessageText.module.css' - -export function MessageText({ text }: { text: string }) { - return
{text}
-} diff --git a/packages/client/ui-primitives/src/user-text.module.css b/packages/client/ui-primitives/src/user-text.module.css index 3e6f8ca4f0..3654d6f990 100644 --- a/packages/client/ui-primitives/src/user-text.module.css +++ b/packages/client/ui-primitives/src/user-text.module.css @@ -20,6 +20,14 @@ white-space: nowrap; } +/* Skill and command tokens read as code: the theme's code family at the + consumer's own size and line height, so the chip sits on the bubble line + like the plain runs around it. `@` chips keep the body face — a file or + session name is a label, not code. */ +.slashChip { + font-family: var(--dsw-font-markdown-code-font-family); +} + /* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's own font: 1em keeps the glyph at the text's size in the bubble (14px + user setting), the queue preview's fixed 13px line, and any future consumer — a diff --git a/packages/client/ui-primitives/src/user-text.tsx b/packages/client/ui-primitives/src/user-text.tsx index 494f49a868..8348b0a930 100644 --- a/packages/client/ui-primitives/src/user-text.tsx +++ b/packages/client/ui-primitives/src/user-text.tsx @@ -4,17 +4,27 @@ * only, and every part renders inline so a single-line message never breaks * across lines. Three decoration sources, by precedence: the wire session form * `@[label](dsh-session:...)` folds to its label; exact session labels - * supplied by an adjacent recall decorate their bare `@label` mention; and - * plain `/name` / `@name` word-boundary tokens decorate by shape alone (sent - * tokens were validated at compose time). + * supplied by an adjacent recall decorate their bare `@label` mention; plain + * `@name` word-boundary tokens decorate by shape alone; and a plain `/name` + * token decorates only when the caller names it: a skill the host actually + * loaded for that message (ui-chat reads the step's `skill-invocation` + * injections) or the command a command-input bubble echoes, so `/123` or a + * stray `/word` stays plain text. A `/name` + * token is whitespace-bounded like the host skill gesture + * (`dsh-tool-skill`), optionally before trailing sentence punctuation, so + * slash paths (`/nfs-hg/xxx`, `/plan.md`) stay plain even for a loaded name. */ import type { ReactNode } from 'react' +import clsx from 'clsx' import { ReferenceIcon } from './ReferenceIcon.tsx' import css from './user-text.module.css' /** The wire form a session chip serializes to; label is the display text. */ const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu +/** Sentence punctuation a bare `@name` token may carry without being part of the reference. */ +const TRAILING_PUNCTUATION_RE = /[.,;:!?,。;:!?]+$/u + interface DecorationRange { readonly start: number readonly end: number @@ -29,9 +39,18 @@ interface DecorationRange { * Split one sent text into inline plain runs and reference chips. * @param text - the logged model text of the message or queue row. * @param sessionLabels - exact session mention labels associated by an adjacent recall. + * @param slashNames - names a `/name` token may decorate as: the skills the + * host loaded for this message, or the command a command bubble echoes + * (unsent queue rows pass none). + * @param slashKind - the chip kind those tokens render as. * @returns inline nodes covering the whole text. */ -export function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { +export function projectUserText( + text: string, + sessionLabels: readonly string[], + slashNames: readonly string[] = [], + slashKind: 'skill' | 'command' = 'skill', +): ReactNode { const ranges: DecorationRange[] = [] SESSION_WIRE_RE.lastIndex = 0 let wire: RegExpExecArray | null @@ -52,15 +71,18 @@ export function projectUserText(text: string, sessionLabels: readonly string[]): start = text.indexOf(label, start + label.length) } } - const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu + // The `/` alternative's lookahead admits the same trailing punctuation set + // TRAILING_PUNCTUATION_RE strips. + const re = /(^|\s)(\/[\w-]+(?=[.,;:!?,。;:!?]*(?:\s|$))|@"[^"\n]+"|@[^\s]+)/gu let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start const rawLabel = m[2] as string // non-optional alternation capture const label = rawLabel.startsWith('@"') ? rawLabel - : rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '') + : rawLabel.replace(TRAILING_PUNCTUATION_RE, '') if (label.length <= 1) continue + if (label.startsWith('/') && !slashNames.includes(label.slice(1))) continue ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) } const rankOf = (range: DecorationRange): number => range.kind === 'session' ? 0 : 1 @@ -88,8 +110,8 @@ export function projectUserText(text: string, sessionLabels: readonly string[]): parts.push( {referenceKind !== undefined && ( diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index eb6b450924..a16d0464cb 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -1,21 +1,12 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import { JsonBlock, MarkdownText } from './markdown-test-components.tsx' import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' afterEach(cleanup) -describe('MessageText', () => { - it('renders the text verbatim', () => { - const { container } = render() - expect(container.textContent).toBe('# line1\n`line2`') - expect(container.querySelector('h1')).toBeNull() - }) -}) - describe('MarkdownText', () => { it('renders CommonMark and GFM elements as semantic DOM', () => { const markdown = [ diff --git a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts index d80d4f5c13..575b705f12 100644 --- a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts +++ b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts @@ -24,4 +24,13 @@ describe('user-text.module.css font-size axis', () => { 'height: 1em', ])) }) + + it('sets slash chips in the code face at the consumer size', () => { + // Skill and command tokens read as code — the family the theme publishes + // for code — while the size and line height stay the consumer's, so the + // chip rides the bubble line like the plain runs around it. + const slashChip = declarations('.slashChip') + expect(slashChip.some(declaration => /^font-family: var\(--dsw-font-/.test(declaration))).toBe(true) + expect(slashChip.some(declaration => /^(font|font-size|line-height):/.test(declaration))).toBe(false) + }) }) diff --git a/packages/client/ui-primitives/tests/user-text.client.spec.tsx b/packages/client/ui-primitives/tests/user-text.client.spec.tsx index 6e1c2e309f..2c14889ad5 100644 --- a/packages/client/ui-primitives/tests/user-text.client.spec.tsx +++ b/packages/client/ui-primitives/tests/user-text.client.spec.tsx @@ -8,12 +8,17 @@ import { describe, expect, it } from 'vitest' import { render } from '@testing-library/react' import { projectUserText } from '../src/user-text.tsx' -const project = (text: string, labels: readonly string[] = []) => - render(
{projectUserText(text, labels)}
).container.querySelector('[data-host]')! +const project = ( + text: string, + labels: readonly string[] = [], + slashNames: readonly string[] = [], + slashKind: 'skill' | 'command' = 'skill', +) => + render(
{projectUserText(text, labels, slashNames, slashKind)}
).container.querySelector('[data-host]')! describe('projectUserText', () => { it('keeps a decorated single-line message on one line: every part is inline', () => { - const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试']) + const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'], ['dsh-acp-test']) expect(host.querySelectorAll('div').length).toBe(0) expect(host.textContent).toBe('反反复复 /dsh-acp-test 执行几个命令测试') const chips = host.querySelectorAll('[data-ref-chip]') @@ -56,12 +61,37 @@ describe('projectUserText', () => { }) it('strips trailing punctuation and skips degenerate tokens', () => { - const host = project('用 /plan。 试试 @。') + const host = project('用 /plan。 试试 @。', [], ['plan']) const chips = [...host.querySelectorAll('[data-ref-chip]')] expect(chips.map(c => c.textContent)).toEqual(['/plan']) expect(host.textContent).toBe('用 /plan。 试试 @。') }) + it('decorates a slash token only when the host resolved it as a skill in that step', () => { + const bare = project('/123') + expect(bare.querySelectorAll('[data-ref-chip]').length).toBe(0) + expect(bare.textContent).toBe('/123') + const unresolved = project('用 /plan 看看') + expect(unresolved.querySelectorAll('[data-ref-chip]').length).toBe(0) + const resolved = project('用 /plan 看看', [], ['plan']) + expect([...resolved.querySelectorAll('[data-ref-chip]')].map(c => [c.getAttribute('data-ref-chip'), c.textContent])) + .toEqual([['skill', '/plan']]) + }) + + it('marks a resolved slash token as a command chip when the caller says so', () => { + const host = project('/goal ship it\nsecond line', [], ['goal'], 'command') + const chips = [...host.querySelectorAll('[data-ref-chip]')] + expect(chips.map(c => [c.getAttribute('data-ref-chip'), c.textContent])).toEqual([['command', '/goal']]) + expect(host.textContent).toBe('/goal ship it\nsecond line') + }) + + it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace or trailing punctuation', () => { + const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md' + const host = project(text, [], ['nfs-hg', 'root-dir', 'plan']) + expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0) + expect(host.textContent).toBe(text) + }) + it('prefers the longer recall label when one nests inside another', () => { const host = project('@会话一 收尾', ['会话', '会话一']) const chips = [...host.querySelectorAll('[data-ref-chip="session"]')] From d976849a6be8489e78b3f0102b81cee5a17856d7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 14:55:41 +0800 Subject: [PATCH 4/5] fix(web): expect the goal command bubble to share the body face --- apps/web/tests/goal-command-presentation.e2e.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 1139b878c6..70ad37a799 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -67,17 +67,26 @@ describe('web e2e: /goal human transcript presentation', () => { const typography = await commandInput.evaluate((element) => { const bubble = element.firstElementChild?.firstElementChild if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing') + const chip = bubble.querySelector('[data-ref-chip="command"]') + if (!(chip instanceof HTMLElement)) throw new Error('command chip is missing') const rootStyle = getComputedStyle(element) const bubbleStyle = getComputedStyle(bubble) + const chipStyle = getComputedStyle(chip) return { fontFamily: bubbleStyle.fontFamily, parentFontFamily: rootStyle.fontFamily, fontSize: bubbleStyle.fontSize, lineHeight: bubbleStyle.lineHeight, + chipText: chip.textContent, + chipFontFamily: chipStyle.fontFamily, + chipFontSize: chipStyle.fontSize, } }) - expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' }) - expect(typography.fontFamily).not.toBe(typography.parentFontFamily) + expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px', chipText: '/goal', chipFontSize: '14px' }) + // The bubble reads in the body face like a user bubble; only the command + // chip carries the code face that marks the echoed token as a command. + expect(typography.fontFamily).toBe(typography.parentFontFamily) + expect(typography.chipFontFamily).not.toBe(typography.fontFamily) const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' }) await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1) expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1) From 1b470211ada8956ed8afbbc7943d1e8b29383572 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 4 Sep 2026 15:15:51 +0800 Subject: [PATCH 5/5] fix(web): address review: index skill-name batches, bound slash tokens at whitespace, chip only the leading goal token --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 2 +- ...web-input-machine-and-slash-pipeline.zh.md | 2 +- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 4 +- .../2026-07-23-web-assistant-markdown.zh.md | 4 +- .../chat-snapshot-builder.ts | 217 ++++++++++++++---- .../tests/skill-name-projector.client.spec.ts | 88 +++++++ .../src/client/input/decorations.ts | 13 +- .../tests/submit-machine.client.spec.ts | 4 +- .../src/client/GoalCommandInputView.tsx | 12 +- .../tests/goal-command-input.client.spec.tsx | 31 +++ .../client/ui-primitives/src/user-text.tsx | 20 +- .../tests/user-text.client.spec.tsx | 9 +- 14 files changed, 332 insertions(+), 82 deletions(-) create mode 100644 packages/client/ui-chat/tests/skill-name-projector.client.spec.ts 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 89baea729d..fbb3f653a0 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 @@ -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-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 500f0acee97f225b0e1a05107f12a5c3ee814bfe -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: cfe0781b945e4570f8f8ca5a6b71a95a2eafadcf +2026-07-25-web-input-machine-and-slash-pipeline.md: 1b9e9d95b5a30efbf297be5fc5f788f9a1ac77c4 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: c9bee217da1dbffaeff69dfe5a2dcf8f0e8e3cb0 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 500f0acee9..1b9e9d95b5 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 @@ -67,7 +67,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - 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 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; a `/name` token also ends at whitespace, the draft end, or trailing sentence punctuation — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. +- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace or the draft end — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path and `/plan。` is prose; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. - Sending is the literal text (no more `` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape. - Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. 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 cfe0781b94..c9bee217da 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 @@ -67,7 +67,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。 -- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白、draft 末尾或句尾标点——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 +- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白或 draft 末尾——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径、`/plan。` 是普通文本;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 - 发送即原文(不再 `` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 671ca8f796..706794e459 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.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-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b -2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99 +2026-07-23-web-assistant-markdown.md: 4063ad647c485be295a55087247681a494dbeabf +2026-07-23-web-assistant-markdown.zh.md: 6b062e155c88bc8c3f3cf048ec463f3db0b4f7bc diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index d2b8e30d77..4063ad647c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -10,7 +10,7 @@ The Web conversation preserves assistant Markdown source through session events, ## Decision -`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. +`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages render through `projectUserText` (inline plain runs plus reference chips) and remain literal. `MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism). @@ -28,7 +28,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.* -**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly. +**Render user prompts and steering as Markdown too.** This formats authored input as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly. **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index c1542d75fa..6b062e155c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -10,7 +10,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 决策 -`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 +`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息经 `projectUserText` 渲染(行内普通片段加引用 chip),并保持按字面渲染。 `MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。 @@ -28,7 +28,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 **将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有。* -**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。 +**把用户提示词与 steering 也按 Markdown 渲染。**这会产生格式化用户输入的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。 **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index ec36b8dc0f..3495b565ca 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -521,67 +521,192 @@ function withSkillNames( return { ...candidate, data } } -/** - * Skill names each direct message's step loaded, keyed by message Node key. - * - * A step's `skill-invocation` injections follow the direct messages the host - * scanned for `/name` gestures and precede the step's first non-message Node - * (the assistant step, a Turn error, a command); every Node of another kind - * therefore closes the batch. Names attach to every direct message of the - * batch: the bubble decorates only the tokens its own text carries. - * @param nodes - every materialized Chat Node, in any order. - * @returns the loaded skill names per direct message key; absent for none. - */ -function skillNamesByMessage(nodes: readonly ChatConversationViewNode[]): Map { - const ordered = [...nodes].sort((left, right) => left.anchorSeq - right.anchorSeq) - const result = new Map() - let batchMessages: string[] = [] - let batchNames: string[] = [] - const flush = (): void => { - if (batchNames.length > 0) for (const key of batchMessages) result.set(key, batchNames) - batchMessages = [] - batchNames = [] - } - for (const node of ordered) { - const candidate = node as ChatNode - if (candidate.kind === 'user' || candidate.kind === 'steering') { - batchMessages.push(node.key) - continue - } - if (candidate.kind !== 'context') { - flush() - continue - } - const name = skillInvocationName(candidate.data.source) - if (name !== null && !batchNames.includes(name)) batchNames.push(name) - } - flush() - return result +/** What skill-name batches are made of; every other Node is transparent. */ +type SlashEntryKind = 'message' | 'skill' | 'boundary' + +interface SlashEntry { + readonly key: string + readonly seq: number + readonly kind: SlashEntryKind + /** The injected skill name of a `skill` entry. */ + readonly name: string | null } -/** Attaches each direct message's step-loaded skill names to its Node. */ -class SkillNameProjector { +/** + * Classify one Node for batching: a direct message, a `skill-invocation` + * context, or a boundary of any other kind. A context that injects no skill + * (workspace rules, the catalog, a recall) is transparent and yields null. + */ +function slashEntryOf(node: ChatConversationViewNode): SlashEntry | null { + const candidate = node as ChatNode + if (candidate.kind === 'user' || candidate.kind === 'steering') { + return { key: node.key, seq: node.anchorSeq, kind: 'message', name: null } + } + if (candidate.kind === 'context') { + const name = skillInvocationName(candidate.data.source) + return name === null ? null : { key: node.key, seq: node.anchorSeq, kind: 'skill', name } + } + return { key: node.key, seq: node.anchorSeq, kind: 'boundary', name: null } +} + +function sameSlashEntry(left: SlashEntry, right: SlashEntry): boolean { + return left.seq === right.seq && left.kind === right.kind && left.name === right.name +} + +/** + * Attaches each direct message's step-loaded skill names to its Node. + * + * A step's `skill-invocation` injections follow the direct messages the host + * scanned for `/name` gestures and precede the step's first Node of any other + * kind, so every non-message, non-context Node closes a batch. Every ended + * Turn publishes its `turn-tail` Node on `turn/end` whatever the reason, so a + * batch never spans Turns, and `step/start` precedes the direct message in + * the log, so no boundary separates a message from its injections. Names + * attach to every direct message of the batch: the bubble decorates only the + * tokens its own text carries. + * + * The index holds only messages, skill injections, and boundaries, ordered by + * `anchorSeq`. An apply re-reads just the batches around the Nodes whose + * classification changed and never scans the store, so an assistant + * streaming frame costs nothing here (the append hot path never scans the + * Chat Nodes). + */ +export class SkillNameProjector { + private readonly entries = new Map() + /** Every indexed entry in `anchorSeq` order. */ + private sorted: SlashEntry[] = [] + + /** + * Rebuild the index from a whole Node set and attach names to its messages. + * @param nodes - every materialized Chat Node, in any order. + * @returns the same Nodes, direct messages carrying their batch's names. + */ replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] { - const names = skillNamesByMessage(nodes) + this.entries.clear() + this.sorted = [] + for (const node of nodes) { + const entry = slashEntryOf(node) + if (entry === null) continue + this.entries.set(entry.key, entry) + this.sorted.push(entry) + } + this.sorted.sort((left, right) => left.seq - right.seq) + const names = new Map() + for (let index = 0; index < this.sorted.length; index++) { + if (this.sorted[index]?.kind === 'boundary') continue + const end = this.runEnd(index) + this.assignRun(index, end, names) + index = end + } return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS)) } + /** + * Fold one incremental upsert set: re-read only the batches around the + * Nodes whose classification changed. + * @param upserts - the changed Nodes. + * @param store - the resident Nodes, read by key for the messages of an affected batch. + * @returns the upserts plus any resident message whose names changed. + */ apply( upserts: readonly ChatConversationViewNode[], store: ChatNodeStore, ): readonly ChatConversationViewNode[] { + const dirty: number[] = [] + for (const node of upserts) { + const next = slashEntryOf(node) + const previous = this.entries.get(node.key) + if (previous !== undefined) { + if (next !== null && sameSlashEntry(previous, next)) continue + this.remove(previous) + dirty.push(previous.seq) + } + if (next === null) continue + this.insert(next) + dirty.push(next.seq) + } + if (dirty.length === 0) return upserts + const names = new Map() + for (const seq of dirty) this.collectAround(seq, names) const byKey = new Map(upserts.map(node => [node.key, node])) - const all = new Map(store.values().map(node => [node.key, node])) - for (const [key, node] of byKey) all.set(key, node) - const names = skillNamesByMessage([...all.values()]) - for (const [key, node] of all) { - const candidate = node as ChatNode - if (candidate.kind !== 'user' && candidate.kind !== 'steering') continue - const next = withSkillNames(node, names.get(key) ?? EMPTY_KEYS) + for (const [key, list] of names) { + const node = byKey.get(key) ?? store.get(key) + if (node === undefined) continue + const next = withSkillNames(node, list) if (next !== node || byKey.has(key)) byKey.set(key, next) } return [...byKey.values()] } + + private insert(entry: SlashEntry): void { + this.sorted.splice(this.lowerBound(entry.seq), 0, entry) + this.entries.set(entry.key, entry) + } + + private remove(entry: SlashEntry): void { + this.sorted.splice(this.sorted.indexOf(entry), 1) + this.entries.delete(entry.key) + } + + /** First index whose seq is at least `seq`. */ + private lowerBound(seq: number): number { + let low = 0 + let high = this.sorted.length + while (low < high) { + const middle = (low + high) >>> 1 + if ((this.sorted[middle]?.seq ?? Number.POSITIVE_INFINITY) < seq) low = middle + 1 + else high = middle + } + return low + } + + /** Last index of the boundary-free run containing `index`. */ + private runEnd(index: number): number { + let end = index + while (end + 1 < this.sorted.length && this.sorted[end + 1]?.kind !== 'boundary') end++ + return end + } + + /** First index of the boundary-free run containing `index`. */ + private runStart(index: number): number { + let start = index + while (start - 1 >= 0 && this.sorted[start - 1]?.kind !== 'boundary') start-- + return start + } + + /** Record the names every message of the run `[start, end]` carries. */ + private assignRun(start: number, end: number, names: Map): void { + const list: string[] = [] + for (let index = start; index <= end; index++) { + const entry = this.sorted[index] + if (entry?.kind === 'skill' && entry.name !== null && !list.includes(entry.name)) list.push(entry.name) + } + for (let index = start; index <= end; index++) { + const entry = this.sorted[index] + if (entry?.kind === 'message') names.set(entry.key, list) + } + } + + /** + * Re-read the run(s) around one changed seq: the run holding a message or + * skill entry, or — for a boundary, or a seq that left the index — the runs + * on both sides of that position. + */ + private collectAround(seq: number, names: Map): void { + const at = this.lowerBound(seq) + const here = this.sorted[at] + if (here !== undefined && here.seq === seq && here.kind !== 'boundary') { + this.assignRun(this.runStart(at), this.runEnd(at), names) + return + } + if (at - 1 >= 0 && this.sorted[at - 1]?.kind !== 'boundary') { + this.assignRun(this.runStart(at - 1), at - 1, names) + } + const right = here !== undefined && here.seq === seq ? at + 1 : at + if (right < this.sorted.length && this.sorted[right]?.kind !== 'boundary') { + this.assignRun(right, this.runEnd(right), names) + } + } } interface LegacyContribution { diff --git a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts new file mode 100644 index 0000000000..0be8dca394 --- /dev/null +++ b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts @@ -0,0 +1,88 @@ +/** + * SkillNameProjector: the step's `skill-invocation` injections attach to the + * direct messages of the same batch, incrementally — an assistant-only apply + * must neither scan the store nor re-emit message Nodes. + */ +import { describe, expect, it } from 'vitest' +import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts' +import type { ChatNodeStore } from '../src/client/contract/snapshot.ts' +import { SkillNameProjector } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' + +function viewNode(kind: string, seq: number, data: Record): ChatConversationViewNode { + return { + key: `${kind}:${seq}`, + kind, + id: String(seq), + target: 'chat', + anchorSeq: seq, + location: { kind: 'session' }, + visibility: 'visible', + data: { kind, seq, time: seq, ...data }, + } as unknown as ChatConversationViewNode +} +const user = (seq: number, text: string) => + viewNode('user', seq, { content: [{ type: 'text', text }], source: { kind: 'user' } }) +const skill = (seq: number, name: string) => + viewNode('context', seq, { content: [], source: { kind: 'skill-invocation', name, form: 'instructions' } }) +const instructions = (seq: number) => + viewNode('context', seq, { content: [], source: { kind: 'agent-instructions', changes: [] } }) +const assistant = (seq: number, text = 'answer') => viewNode('assistant-step', seq, { text }) + +function storeOf(nodes: readonly ChatConversationViewNode[]): ChatNodeStore & { valuesCalls: number } { + const byKey = new Map(nodes.map(node => [node.key, node])) + const store = { + valuesCalls: 0, + get: (key: string) => byKey.get(key), + values: () => { + store.valuesCalls += 1 + return [...byKey.values()] + }, + source: () => { throw new Error('unused') }, + processSource: () => { throw new Error('unused') }, + } + return store +} + +const names = (node: ChatConversationViewNode | undefined) => + (node?.data as { skillNames?: readonly string[] } | undefined)?.skillNames + +describe('SkillNameProjector', () => { + it('attaches a batch\'s injected skill names on replace and leaves other Nodes untouched', () => { + const projector = new SkillNameProjector() + const answer = assistant(6) + const out = projector.replace([user(2, '/demo go'), instructions(4), skill(5, 'demo'), answer, user(10, '/demo later?')]) + expect(names(out[0])).toEqual(['demo']) + expect(names(out[4])).toBeUndefined() + expect(out[3]).toBe(answer) + }) + + it('an assistant-only apply neither scans the store nor re-emits message Nodes', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)]) + const store = storeOf(replaced) + const frame = assistant(6, 'answer grows') + const out = projector.apply([frame], store) + expect(out).toEqual([frame]) + expect(store.valuesCalls).toBe(0) + }) + + it('a late skill injection updates only the direct messages of its batch', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), assistant(6), user(10, 'unrelated')]) + const store = storeOf(replaced) + const injection = skill(3, 'demo') + const out = projector.apply([injection], store) + expect(out.map(node => node.key).sort()).toEqual(['context:3', 'user:2']) + expect(names(out.find(node => node.key === 'user:2'))).toEqual(['demo']) + }) + + it('a boundary arriving inside a batch splits it and drops the names past it', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(4, 'demo'), user(8, '/demo again')]) + expect(names(replaced[2])).toEqual(['demo']) + const store = storeOf(replaced) + const out = projector.apply([assistant(6)], store) + expect(out.map(node => node.key).sort()).toEqual(['assistant-step:6', 'user:8']) + expect(names(out.find(node => node.key === 'user:8'))).toBeUndefined() + }) +}) diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index 64a19c2828..e221abf64b 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -24,19 +24,18 @@ export interface TextRefRange { const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g /** - * What may follow a `/name` token: whitespace or the draft end, optionally - * after trailing sentence punctuation. The host skill gesture - * (`dsh-tool-skill`) is whitespace-bounded, so `/nfs-hg/xxx` or `/plan.md` - * is a path, never a reference. + * What may follow a `/name` token: whitespace or the draft end, the boundary + * the host skill gesture (`dsh-tool-skill`) requires, so `/nfs-hg/xxx`, + * `/plan.md`, and `/plan。` are prose, never a reference. */ -const SLASH_TOKEN_END_RE = /^[.,;:!?,。;:!?]*(?:\s|$)/ +const SLASH_TOKEN_END_RE = /^(?:\s|$)/ /** * Scan the draft for plain-text reference tokens against the hot lexicons. * Word-boundary discipline: the trigger must sit at the draft * start or after whitespace ('x/name' never matches); the name must be an - * exact lexicon member; a `/name` token must end at whitespace, the draft - * end, or trailing sentence punctuation ('/name/x' is a path). + * exact lexicon member; a `/name` token must end at whitespace or the draft + * end ('/name/x' is a path, '/name。' is prose). * @param draft - draft text. * @param lexicon - per-trigger name lists (a missing trigger scans nothing). * @returns matched ranges in draft order. diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index faf1f4e33f..d2ba36729b 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -351,8 +351,8 @@ describe('decorations: scanTextRefs', () => { expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([]) }) - it('a "/" token may end at trailing punctuation before whitespace', () => { - expect(scanTextRefs('/goal。 then', lexicon)).toEqual([{ start: 0, end: 5, trigger: '/' }]) + it('a "/" token glued to punctuation is not a reference: the host gesture is whitespace-bounded', () => { + expect(scanTextRefs('/goal。 then /goal, now', lexicon)).toEqual([]) }) it('word boundary: a trigger glued to text never matches', () => { diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx index 43f3dc30b2..8d9742d45d 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx @@ -11,13 +11,18 @@ type GoalCommandInputViewProps = /** * Right-aligned `/goal` input bubble without ordinary message actions. The * echoed line decorates its leading `/goal` token as a command chip — the run - * this Node projects is the fact that the token was a command — and keeps - * the objective as plain text. + * this Node projects is the fact that that token was a command — and keeps + * the objective, `/goal` mentions included, as plain text. */ export const GoalCommandInputView = memo(function GoalCommandInputView({ node, t, }: GoalCommandInputViewProps) { const data: GoalCommandInputData = node.data + // Only the leading token is the executed command; the rest of the line is + // the objective, where a further `/goal` is prose. + const split = data.text.search(/\s/u) + const head = split === -1 ? data.text : data.text.slice(0, split) + const rest = split === -1 ? '' : data.text.slice(split) return (
- {projectUserText(data.text, [], [GOAL_COMMAND], 'command')} + {projectUserText(head, [], [GOAL_COMMAND], 'command')} + {rest !== '' && projectUserText(rest, [])}
diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index 6f12c5e456..ad03572d4c 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -137,4 +137,35 @@ describe('goal command input projection', () => { const chips = [...bubble.querySelectorAll('[data-ref-chip]')] expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']]) }) + + it('renders a bare /goal as one command chip and nothing else', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:bare', + data: { commandId: 'command-goal', text: '/goal', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '指令输入' }) + expect(bubble.textContent).toBe('/goal') + expect([...bubble.querySelectorAll('[data-ref-chip]')].map(chip => chip.textContent)).toEqual(['/goal']) + }) + + it('decorates only the leading command token: a /goal inside the objective stays plain', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:two', + data: { commandId: 'command-goal', text: '/goal 检查 /goal 的语法', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '指令输入' }) + expect(bubble.textContent).toBe('/goal 检查 /goal 的语法') + const chips = [...bubble.querySelectorAll('[data-ref-chip]')] + expect(chips.map(chip => chip.textContent)).toEqual(['/goal']) + }) }) diff --git a/packages/client/ui-primitives/src/user-text.tsx b/packages/client/ui-primitives/src/user-text.tsx index 8348b0a930..f0b89cfbb4 100644 --- a/packages/client/ui-primitives/src/user-text.tsx +++ b/packages/client/ui-primitives/src/user-text.tsx @@ -2,17 +2,17 @@ * Display projection of reference forms in sent user text (bubble and queue * rows). The logged model text remains the single truth; this is presentation * only, and every part renders inline so a single-line message never breaks - * across lines. Three decoration sources, by precedence: the wire session form + * across lines. Four decoration sources, by precedence: the wire session form * `@[label](dsh-session:...)` folds to its label; exact session labels * supplied by an adjacent recall decorate their bare `@label` mention; plain * `@name` word-boundary tokens decorate by shape alone; and a plain `/name` - * token decorates only when the caller names it: a skill the host actually + * token decorates only when the caller names it — a skill the host actually * loaded for that message (ui-chat reads the step's `skill-invocation` - * injections) or the command a command-input bubble echoes, so `/123` or a - * stray `/word` stays plain text. A `/name` - * token is whitespace-bounded like the host skill gesture - * (`dsh-tool-skill`), optionally before trailing sentence punctuation, so - * slash paths (`/nfs-hg/xxx`, `/plan.md`) stay plain even for a loaded name. + * injections) or the command a command-input bubble echoes — so `/123` or a + * stray `/word` stays plain text. A `/name` token is whitespace-bounded like + * the host skill gesture (`dsh-tool-skill`): it ends at whitespace or the + * text end, so slash paths (`/nfs-hg/xxx`, `/plan.md`) and punctuation-glued + * tokens (`/plan。`) stay plain even for a loaded name. */ import type { ReactNode } from 'react' import clsx from 'clsx' @@ -71,9 +71,9 @@ export function projectUserText( start = text.indexOf(label, start + label.length) } } - // The `/` alternative's lookahead admits the same trailing punctuation set - // TRAILING_PUNCTUATION_RE strips. - const re = /(^|\s)(\/[\w-]+(?=[.,;:!?,。;:!?]*(?:\s|$))|@"[^"\n]+"|@[^\s]+)/gu + // A `/` token ends at whitespace or the text end like the host skill + // gesture; only `@` tokens shed sentence punctuation below. + const re = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start diff --git a/packages/client/ui-primitives/tests/user-text.client.spec.tsx b/packages/client/ui-primitives/tests/user-text.client.spec.tsx index 2c14889ad5..b65bb8f43d 100644 --- a/packages/client/ui-primitives/tests/user-text.client.spec.tsx +++ b/packages/client/ui-primitives/tests/user-text.client.spec.tsx @@ -60,10 +60,11 @@ describe('projectUserText', () => { expect(host.querySelectorAll('[data-ref-chip="session"]').length).toBe(2) }) - it('strips trailing punctuation and skips degenerate tokens', () => { + it('keeps a punctuation-glued slash token plain and skips degenerate tokens', () => { + // The host skill gesture ends at whitespace or the text end, so `/plan。` + // never loads a skill; the bubble must not suggest otherwise. const host = project('用 /plan。 试试 @。', [], ['plan']) - const chips = [...host.querySelectorAll('[data-ref-chip]')] - expect(chips.map(c => c.textContent)).toEqual(['/plan']) + expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0) expect(host.textContent).toBe('用 /plan。 试试 @。') }) @@ -85,7 +86,7 @@ describe('projectUserText', () => { expect(host.textContent).toBe('/goal ship it\nsecond line') }) - it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace or trailing punctuation', () => { + it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace', () => { const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md' const host = project(text, [], ['nfs-hg', 'root-dir', 'plan']) expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)