mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into fix/web-ui-optimization
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
|
||||
2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 90473861c199f326f4b3635580c885517f0d612b
|
||||
|
||||
@@ -45,7 +45,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
|
||||
| runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` |
|
||||
| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
|
||||
| store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
|
||||
| business | `I` | inject return type | plain data + callbacks (hooks banned) |
|
||||
| business | `I` | inject return type | plain data + callbacks; a reserved `hooks` compartment of bare observables arrives bound as `use<Name>` selector hooks (`InjectFace<I>`) |
|
||||
|
||||
`sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
|
||||
|
||||
@@ -80,11 +80,11 @@ Store scope is **derived from the mounting entry's scope** (session slot → one
|
||||
|
||||
### inject: the registrant's business face, on its own ctx
|
||||
|
||||
An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
|
||||
An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks, plus at most the reserved `hooks` compartment: a map of bare observable sources (getSnapshot+subscribe) the renderer binds into `use<Name>` selector hooks before the face reaches the component — the registrant-private twin of the provide channel's hooks compartment, for reactive facts too niche for the global standard kit (composer notices/lexicon, the settings nav rows). Components never receive the raw sources, so business code still contains no subscription machinery. Everything else stays plain: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hand-made hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
|
||||
|
||||
### Data-boundary discipline
|
||||
|
||||
Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
|
||||
Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` plus the hooks bound from provide contributions and inject `hooks` compartments — every one synthesized by the renderer's single binding machinery; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
|
||||
|
||||
### Tree context and the renderer seam
|
||||
|
||||
@@ -111,7 +111,7 @@ Render authority is enforceable rather than conventional: who renders what is a
|
||||
| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks |
|
||||
| Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything |
|
||||
| `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn |
|
||||
| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine |
|
||||
| Business hand-made hooks / raw observables in component props | Every plugin becomes its own subscription machine; the inject `hooks` compartment carries the same facts through the one audited binding machinery |
|
||||
| Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation |
|
||||
| Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact |
|
||||
| `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) |
|
||||
|
||||
@@ -45,7 +45,7 @@ ctx.slots.register({
|
||||
| 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` |
|
||||
| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` |
|
||||
| store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
|
||||
| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
|
||||
| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use<Name>` 选择器 hook 到达(`InjectFace<I>`) |
|
||||
|
||||
凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
|
||||
|
||||
@@ -80,11 +80,11 @@ store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会
|
||||
|
||||
### inject:注册方的业务面,立足自己的 ctx
|
||||
|
||||
inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
|
||||
inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值是普通数据与回调,至多外加保留键 `hooks` 格:一张裸 observable source(getSnapshot+subscribe)表,渲染器在业务面抵达组件前把每个 source 绑成 `use<Name>` 选择器 hook——即 provide 通道 hooks 格的注册方私有孪生,供太小众、不该进全局标准件的响应式事实(composer 的 notices/lexicon、settings 导航行)取用。组件永远收不到裸 source,业务代码因此仍零订阅机械。其余保持普通:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁手造 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
|
||||
|
||||
### 数据界线纪律
|
||||
|
||||
hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
|
||||
hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 五席,加上 provide 贡献与 inject `hooks` 格绑出的 hook——全部出自渲染器同一台绑定机械;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
|
||||
|
||||
### 树上语境与渲染器安装缝
|
||||
|
||||
@@ -111,7 +111,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
|
||||
| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 |
|
||||
| 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 |
|
||||
| `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 |
|
||||
| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 |
|
||||
| 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械;inject `hooks` 格让同样的事实走那一台受审计的绑定机械 |
|
||||
| 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
|
||||
| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
|
||||
| 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: 09afe6d9e879ae7529d309c3b5e656be849fa543
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151
|
||||
|
||||
@@ -90,7 +90,7 @@ The sole provisioning path by which session slot components fetch their own sess
|
||||
Slot scope is the closed set `root | session-maybe | session`:
|
||||
|
||||
- `root` receives only the global standard kit, with no session identity or provisioning.
|
||||
- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates, while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
|
||||
- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
|
||||
- `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
|
||||
|
||||
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip.
|
||||
|
||||
@@ -90,7 +90,7 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件
|
||||
slot scope 是闭集 `root | session-maybe | session`:
|
||||
|
||||
- `root` 只拿全局标准件,不接收 session 身份或供数。
|
||||
- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 驱动这条更新,`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
|
||||
- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
|
||||
- `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。
|
||||
|
||||
`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view,composer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b
|
||||
2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md
|
||||
2026-07-25-web-command-surfaces-and-assembly.md: 4c4a400abab940baebc1699fc15b709419865f0c
|
||||
2026-07-25-web-command-surfaces-and-assembly.zh.md: c0acd1ecc5998a0ec488a1f13ed99ae4a93a240b
|
||||
|
||||
@@ -28,8 +28,8 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx
|
||||
|
||||
### Reference sources (seeing only projections plus their own apply closures, on the root ctx)
|
||||
|
||||
- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
|
||||
- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream).
|
||||
- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
|
||||
- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream).
|
||||
|
||||
### Fixture command routing and assembly
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ Status: implemented
|
||||
|
||||
### 引用源(只见投影 + 自家 apply 闭包的 root ctx)
|
||||
|
||||
- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。
|
||||
- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。
|
||||
- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。
|
||||
- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。
|
||||
|
||||
### fixture 命令路由与装配
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: acbd132a5fdb97a4098064aae689dfca604ad4b7
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 158650a41b47f98037a1b3e610d9294694c55a8c
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf
|
||||
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640
|
||||
|
||||
@@ -81,10 +81,10 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
|
||||
skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived:
|
||||
|
||||
- PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
|
||||
- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the controller aggregates it into the `lexicon()` public surface.
|
||||
- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
|
||||
- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan.
|
||||
- Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
|
||||
- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Known limitation kept as-is: with the lexicon not warm at paste / cold start there is no decoration — it lights up only after typing `/` opens the menu once.
|
||||
- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
|
||||
|
||||
### Per-session provide contributions and the private keyboard surface
|
||||
|
||||
|
||||
@@ -81,10 +81,10 @@ occurrence 表与 chip 三投影:
|
||||
skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draft,chip 视觉纯派生:
|
||||
|
||||
- PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同契约:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
|
||||
- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);controller 聚合为 `lexicon()` 公面。
|
||||
- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` snapshot store(每次 source 通知重拉);scope 出生后才注册的 source 由 service 广播给活 controller,补 warm 并并入名录。
|
||||
- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。
|
||||
- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)。
|
||||
- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。已知局限维持现状:粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮。
|
||||
- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
|
||||
|
||||
### per-session 供数贡献与键盘私面
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.md: e2e951cd9f78b319a701a3e60afba48786633f03
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 55302b509e28520f90f6cd820e4962be014318cc
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Stable snapshot refresh volatiles
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-stable-snapshot-refresh-volatiles.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
ACP snapshot comparison normalizes generated UUIDs, cwd aliases, spill locators, embedded event times, and omitted-byte counts, but refresh write-back persisted the fresh raw values. A behaviorally unchanged refresh therefore rewrote fixtures with new randomness or host-specific path spellings even though the comparison contract considered both logs equal.
|
||||
|
||||
## Decision
|
||||
|
||||
Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value.
|
||||
|
||||
Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log.
|
||||
|
||||
Object fields align by key. Array elements align only when all corresponding arrays have the same length; otherwise the fresh array wins. Strings remain atomic leaves. Existing packed-chunk timing alignment and inserted-title handling remain separate because they align logical events rather than values inside one record.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use deterministic UUIDs and spill filenames in snapshot deployments.** Replacing production randomness would weaken the security shape under test or require test-only behavior in storage and approval implementations.
|
||||
|
||||
**Commit normalized fixtures.** Tokenized session logs would stop being raw replay inputs and would cause a broad fixture migration unrelated to the write-back defect.
|
||||
|
||||
**Preserve a whole record when its normalized form is unchanged.** This is simpler but churns a random field whenever another field in the same record changes semantically. Leaf-level preservation keeps those decisions independent.
|
||||
|
||||
## Consequences
|
||||
|
||||
Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data.
|
||||
|
||||
Focused unit coverage pins recursive object/array behavior, correlated IDs, ambiguous-layout fallback, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 稳定快照刷新中的易变值
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-stable-snapshot-refresh-volatiles.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别名、spill locator、嵌入的事件时间和省略字节数,但刷新写回会持久化本次生成的原始值。因此,即使比较契约将两份日志视为相等,一次行为未发生变化的刷新仍会用新的随机值或宿主特有的路径写法改写 fixture(测试前置数据)。
|
||||
|
||||
## 决策
|
||||
|
||||
刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。
|
||||
|
||||
复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用规范化字符串复用。
|
||||
|
||||
对象字段按键对齐。只有所有对应数组长度相同时,才对齐其元素;否则以本次生成的数组为准。字符串始终作为不可拆分的叶节点。现有的打包分片计时对齐与插入标题处理仍保持独立,因为它们对齐的是逻辑事件,而非单条记录内的值。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在快照部署中使用确定性的 UUID 和 spill 文件名。** 替换生产环境使用的随机性会削弱测试所要验证的安全属性,或者要求存储与审批实现引入仅用于测试的行为。
|
||||
|
||||
**提交归一化后的 fixture。** token 化的会话日志将不再是原始回放输入,并会引发与写回缺陷无关的大范围 fixture 迁移。
|
||||
|
||||
**当整条记录的归一化形式未变时保留整条记录。** 这种做法更简单,但同一记录中的另一个字段发生语义变化时,也会改写其中的随机字段。按叶节点保留可使这些决策彼此独立。
|
||||
|
||||
## 后果
|
||||
|
||||
重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。
|
||||
|
||||
聚焦的单元测试固定了递归处理对象与数组的行为、关联 ID、有歧义布局时的回退、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。
|
||||
@@ -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-21-cross-session-references.md
|
||||
2026-07-21-cross-session-references.md: 18dab5fb85f2201258e2f15c3c069d2668ae80d3
|
||||
2026-07-21-cross-session-references.zh.md: 44e33ab1ed762b08b6be30d66b82396a5b519c02
|
||||
2026-07-21-cross-session-references.md: a6c7c3b3e89e032d6d807a25a7661a875f9f7bec
|
||||
2026-07-21-cross-session-references.zh.md: ef78cd7ecee3e2625b446139d7f22e16d0bf1dfa
|
||||
|
||||
@@ -14,7 +14,7 @@ TUI users need to bring relevant work from another conversation into one new mes
|
||||
|
||||
`dsh-session:<base64url(JSON.stringify(sessionId))>` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)`; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text.
|
||||
|
||||
The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: v1 discovery filters only id and cwd, and future title/body search can replace the candidate layer without changing reference identity or preparation.
|
||||
The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: discovery matches id, cwd, or the latest folded title, while message bodies remain outside the candidate layer. Non-empty queries batch title observations across the visible corpus with bounded persisted-log concurrency and cancellation; a dedicated title index can replace that discovery path without changing reference identity or preparation.
|
||||
|
||||
## Snapshot and projection
|
||||
|
||||
@@ -32,7 +32,7 @@ Reference preparation is not a new steering protocol and does not create a turn
|
||||
|
||||
## Host adapters
|
||||
|
||||
TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the readable direct content as the user message, and renders session-reference source metadata as a compact source list instead of exposing the complete JSON in the terminal.
|
||||
TUI combines session candidates with the existing `@` file provider. Candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the readable direct content as the user message, and renders session-reference source metadata as a compact source list instead of exposing the complete JSON in the terminal.
|
||||
|
||||
The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services.
|
||||
|
||||
@@ -53,7 +53,7 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b
|
||||
|
||||
## Verification
|
||||
|
||||
Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, prompt blocking, admission-time staging, send/steer placement, title isolation, missing capability, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains a sourced snapshot message followed by the readable current prompt, without either shadowed string.
|
||||
Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, terminal-control escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, prompt blocking, admission-time staging, send/steer placement, title isolation, missing capability, and compact TUI replay. One keyless terminal snapshot types a title-only substring against an opaque session id and pins the rendered candidate. Another keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains a sourced snapshot message followed by the readable current prompt, without either shadowed string.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但
|
||||
|
||||
`dsh-session:<base64url(JSON.stringify(sessionId))>` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。
|
||||
|
||||
该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是功能依赖:v1 的候选发现只按 id 和 cwd 过滤;未来的标题或正文搜索可以替换候选层,而无需改变引用标识或准备过程。
|
||||
该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是功能依赖:候选发现会匹配 id、cwd 或最新折叠后的标题,而消息主体不进入候选层。非空查询会对可见语料中的标题观察结果执行批处理,以有界并发读取持久化日志,并支持取消;专用标题索引可以替换这条发现路径,而无需改变引用标识或准备过程。
|
||||
|
||||
## 快照与投影
|
||||
|
||||
@@ -32,7 +32,7 @@ TUI 负责快照/直接消息事务,不扩展通用收件箱记录。在 nex
|
||||
|
||||
## 宿主适配器
|
||||
|
||||
TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把可读的直接内容渲染为用户消息,并把会话引用来源元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。
|
||||
TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把可读的直接内容渲染为用户消息,并把会话引用来源元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。
|
||||
|
||||
[仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。
|
||||
|
||||
@@ -53,7 +53,7 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选
|
||||
|
||||
## 验证
|
||||
|
||||
单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、提示词阻止、准入期间的暂存、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含一条带来源的快照消息,后面跟随可读的当前提示词,并且不包含任一被遮蔽的字符串。
|
||||
单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、终端控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、提示词阻止、准入期间的暂存、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。一个无密钥终端快照会在会话 id 不透明的情况下输入一个只与标题匹配的子串,并固定渲染出的候选项。另一个无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含一条带来源的快照消息,后面跟随可读的当前提示词,并且不包含任一被遮蔽的字符串。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: a1481bb9e2c3abfc3111dce8a1c38835436e3e13
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 64a55f6aa0cb4b9a4a5efc625ece6a0800217f52
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 96ffd772810a7908ff452967aa0be0e540bc2d8c
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: a7062bd356eb77c76799a64c980198200d140e8f
|
||||
|
||||
@@ -12,8 +12,8 @@ The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-jsonrpc`, the [single-exe
|
||||
|
||||
Three packages, layered exactly like the existing Python stack, plus one seam registration:
|
||||
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/sdk-protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message).
|
||||
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/sdk-client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Session-tree scoping from `subagent.started` lineage edges is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`).
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/sdk-protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message).
|
||||
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/sdk-client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`).
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`.
|
||||
- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself.
|
||||
|
||||
@@ -38,10 +38,12 @@ Four tiers, per [testing policy](../../../../docs/testing.md):
|
||||
|
||||
**Give the TS SDK bundled-runtime resolution parity with Python.** Python's carrier resolution exists to ship wheels to users without Node. A TypeScript consumer definitionally has Node and (in-repo) the workspace; inventing a distribution story with no consumer violates the require-current-need rule. Deferred until a real npm-distribution consumer appears.
|
||||
|
||||
**Export source modules, normalization helpers, and subscription producer operations.** These are implementation seams with no caller need; exposing them would make callers learn how the client validates and distributes wire input. The package roots instead enumerate the supported client and protocol interfaces, and the client re-exports the one protocol error callers must distinguish.
|
||||
|
||||
**Reuse `dsh-acp-snapshot`'s `runScenario` for the SDK snapshots.** That harness speaks ACP (`ClientSideConnection`, `InputStep` scripts). The SDK suite's whole point is to drive the *SDK client* as the entry surface; it reuses the normalize/refresh library layer (`normalizeSessionLog`, `refreshFixtureReplacements`, …) and leaves the ACP driver alone.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Bought**: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors and structured turn reasons; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself.
|
||||
**Bought**: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself.
|
||||
|
||||
**Paid**: a third package in the `sdk/` group and a fourth subagent backend to keep current; the SDK backend boots a complete plugin tree per child (heavier per-run than an ACP child; pooling remains future work, same as ACP); the wire still has no cancel method, so both the SDK's `RequestTimeoutError` and the backend's dispose settle locally while the server-side turn runs on until process teardown; fixtures for the snapshot suite were recorded against `deepseek-v4-flash` and re-record on model-behavior drift like every other recorded corpus.
|
||||
|
||||
@@ -12,8 +12,8 @@ stdio JSON-RPC 服务表面(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执
|
||||
|
||||
三个包,分层与既有 Python 栈完全一致,外加一个接缝注册:
|
||||
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/sdk-protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。
|
||||
- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(生成、分帧、通知扇出、有类型的错误表面、经共享处置阶梯关闭至静止)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费者的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess 接缝的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/sdk-protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。
|
||||
- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(生成、分帧、通知扇出、有类型的错误表面、经共享处置阶梯关闭至静止)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费者的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess 接缝的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,以 `subagent-acp` 的同胞结构组织:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样的经 `onError` 汇把结果压平为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未跑回合就尘埃落定的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。
|
||||
- **subagent 接缝增长出 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境擦除、进程树拆除)属于 `dsh-subprocess` 接缝;`subagent-acp` 经 `ctx.subprocess` 生成子进程,本后端则经 SDK 客户端生成(subprocess README 记载的 SDK 托管传输例外)并自行应用接缝的 `scrubbedParentEnv()`。
|
||||
|
||||
@@ -38,10 +38,12 @@ stdio JSON-RPC 服务表面(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执
|
||||
|
||||
**给 TS SDK 与 Python 对等的捆绑运行时解析。** Python 的载体解析是为了给没有 Node 的用户发 wheel。TypeScript 消费者定义上就有 Node 且(仓库内)有工作区;为不存在的消费者发明发行故事违反"要求当前需求"规则。推迟到真实 npm 发行消费者出现。
|
||||
|
||||
**导出源模块、规范化辅助函数和订阅投递端操作。** 这些都是调用方不需要的实现接缝;暴露它们会让调用方不得不理解客户端如何校验与分发线输入。各包根转而枚举受支持的客户端接口与协议接口,客户端则只重新导出调用方必须区分的那一种协议错误。
|
||||
|
||||
**复用 `dsh-acp-snapshot` 的 `runScenario` 做 SDK 快照。** 那个 harness 说 ACP(`ClientSideConnection`、`InputStep` 脚本)。SDK 套件的全部意义就是以 *SDK 客户端*为入口表面;它复用 normalize/refresh 库层(`normalizeSessionLog`、`refreshFixtureReplacements`……),不动 ACP 驱动器。
|
||||
|
||||
## Consequences
|
||||
|
||||
**买到**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费者获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化回合原因;subagent 接缝获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是接缝 Note 预期的递归组合故事;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。
|
||||
**买到**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费者获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化回合原因,包根也只暴露归调用方所有的操作;subagent 接缝获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是接缝 Note 预期的递归组合故事;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。
|
||||
|
||||
**付出**:`sdk/` 组多了第三个包、subagent 多了第四个要保持最新的后端;SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作);线上仍无取消方法,SDK 的 `RequestTimeoutError` 与后端的 dispose 都只在本地定格、服务器侧回合继续跑到进程拆除为止;快照夹具录制于 `deepseek-v4-flash`,与其他录制语料一样随模型行为漂移而重录。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-explicit-change-scope-report.md
|
||||
2026-07-27-explicit-change-scope-report.md: 2cce567940a142ed1f4699f4dc67322ed69565e9
|
||||
2026-07-27-explicit-change-scope-report.zh.md: cc08df3ee4f3d681bc4ae8b2b0eab3588dfe1a73
|
||||
2026-07-27-explicit-change-scope-report.md: ed09ffc44252e1e571d50537b3471cad8d68d8f9
|
||||
2026-07-27-explicit-change-scope-report.zh.md: e13092395d6708b1ec030d13365853a5eaecba55
|
||||
|
||||
@@ -12,13 +12,13 @@ An incorrect range undermines evidence selection because it can omit affected pa
|
||||
|
||||
## Decision
|
||||
|
||||
The root `change-scope` command requires `--base <ref>`, accepts `--head <ref>` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes; the repository root, branch, upstream, and every path are decoded as strict UTF-8. An invalid value aborts the report before output instead of substituting characters or collapsing distinct values.
|
||||
The root `change-scope` command requires `--base <ref>`, accepts `--head <ref>` with `HEAD` as the default, and writes one versioned JSON report. It resolves both inputs to commits with ambiguity detection and requires one merge base before rendering. The report records the repository root without normalizing legal path whitespace, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes; the repository root and every path are decoded as strict UTF-8. An invalid value aborts the report instead of substituting characters or collapsing distinct values.
|
||||
|
||||
Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Every Git probe disables configured filesystem monitors and optional lock-taking; diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible.
|
||||
|
||||
The command never guesses or fetches a base, queries a hosting provider, or selects tests. Each calling workflow verifies current remote or stack state, supplies the base explicitly, and uses the factual report as input to semantic review or evidence selection.
|
||||
|
||||
Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, a configured filesystem monitor remaining unexecuted, distinct non-UTF-8 POSIX paths and branch or upstream names failing without partial output, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting.
|
||||
Focused temporary-repository tests cover explicit and stacked refs, every dirty layer, legal path whitespace, strict path decoding, inert probes, invalid refs, the deterministic schema, and unchanged refs, index, config, and status after reporting.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,9 +30,11 @@ Focused temporary-repository tests cover a fresh branch tracking `origin/master`
|
||||
|
||||
**Generate required tests from changed paths.** Paths cannot establish behavior reached through configuration, dynamic loading, subprocesses, workers, built artifacts, or providers. Evidence selection remains judgment under the pre-push workflow.
|
||||
|
||||
**Report current branch and upstream and maintain a parallel human renderer.** Callers already verify branch and base state before invocation, no consumer uses those fields, and formatted prose duplicates the JSON schema without improving path completeness.
|
||||
|
||||
## Consequences
|
||||
|
||||
The explicit input makes an incorrect base possible but visible: both input refs and all three resolved commit IDs appear in either output form. Callers pay the small cost of verifying and fetching the live base before running the command.
|
||||
The explicit input makes an incorrect base possible but visible: both input refs and all three resolved commit IDs appear in the report. Callers pay the small cost of verifying and fetching the live base before running the command.
|
||||
|
||||
The string schema deliberately cannot represent non-UTF-8 path bytes. A repository containing them must rename those paths before it can produce a report, preserving exact scope instead of returning a lossy one.
|
||||
|
||||
|
||||
@@ -12,13 +12,13 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
根目录的 `change-scope` 命令要求提供 `--base <ref>`,接受可选的 `--head <ref>`(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会输出结果。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分;仓库根目录、分支、上游和每条路径都以严格 UTF-8 解码。遇到无效值时,命令会在写出任何结果前失败,不会用替换字符代替无效字节或把不同值合并为一条。
|
||||
根目录的 `change-scope` 命令要求提供 `--base <ref>`,接受可选的 `--head <ref>`(默认为 `HEAD`),并写出一份带版本号的 JSON 报告。该命令会检测歧义,将两个输入解析为 commit,并要求二者恰好有一个合并基点,之后才会呈现报告。报告记录仓库根目录(不对路径中的合法空白字符作规范化处理)、输入引用、解析后的基准、头部与合并基点 commit ID,以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分;仓库根目录和每条路径都以严格 UTF-8 解码。遇到无效值时,报告会中止,不会用替换字符代替无效字节或把不同值合并为一条。
|
||||
|
||||
已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit,各类未提交路径集合仍始终描述当前 worktree 与索引。每次 Git 探测都会禁用配置的文件系统监视器和可选加锁;diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。
|
||||
|
||||
该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。
|
||||
|
||||
聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;配置的文件系统监视器不会执行;互异的非 UTF-8 POSIX 路径、分支名或上游名会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。
|
||||
聚焦的临时仓库测试覆盖显式引用与堆叠引用、所有未提交改动层、合法路径空白、严格路径解码、无副作用的探测、无效引用、确定性 schema,以及报告前后不变的引用、索引、配置与状态。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -30,9 +30,11 @@ Status: implemented
|
||||
|
||||
**根据变更路径生成必需的测试。** 变更路径无法揭示经由配置、动态加载、子进程、worker、构建产物或提供方触达的行为。pre-push 工作流仍须通过判断来选择证据。
|
||||
|
||||
**报告当前分支与上游,并维护并行的人类可读渲染器。** 调用方在调用前已经验证分支和基准状态,没有消费方使用这些字段,而格式化文字只会重复 JSON schema,并不能提高路径完整性。
|
||||
|
||||
## 结果
|
||||
|
||||
显式输入仍可能指定错误的基准,但这种错误是可见的:两种输出格式都会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。
|
||||
显式输入仍可能指定错误的基准,但这种错误是可见的:报告中会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本,在运行该命令前验证实时基准并从远端获取它。
|
||||
|
||||
字符串 schema 有意不表示非 UTF-8 路径字节。含有这类路径的仓库必须先重命名这些路径才能生成报告,以此保持范围精确,而非返回有损结果。
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
|
||||
|
||||
# Reviewing a DeepSeek-Harness PR
|
||||
|
||||
**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm run change-scope --base <verified-base-ref> --head <verified-head-ref>` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits.
|
||||
**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm --silent run change-scope --base <verified-base-ref> --head <verified-head-ref>` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits.
|
||||
|
||||
## Sources of truth
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Run the placement test in the standard's taxonomy table, then check the constrai
|
||||
|
||||
## Auditing the corpus
|
||||
|
||||
The audit is a hunt for the standard's slop checklist, cheapest probes first. Verify and fetch the PR's live base, then run `pnpm run change-scope --base <verified-base-ref>` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and repeat the audit for prose introduced by the new base rather than relying on the earlier result.
|
||||
The audit is a hunt for the standard's slop checklist, cheapest probes first. Verify and fetch the PR's live base, then run `pnpm --silent run change-scope --base <verified-base-ref>` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and repeat the audit for prose introduced by the new base rather than relying on the earlier result.
|
||||
|
||||
1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers.
|
||||
2. Hunt narrated history: `rg -n "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts' --glob '!vendor/**'` and keep only contrasts against a live alternative. Keep the vendor exclusion last so include globs cannot override it.
|
||||
|
||||
@@ -19,10 +19,10 @@ git rev-parse --show-toplevel
|
||||
2. Verify the live PR base or stack parent, fetch that ref, and inspect the complete scope against it.
|
||||
|
||||
```sh
|
||||
pnpm run change-scope --base <verified-base-ref>
|
||||
pnpm --silent run change-scope --base <verified-base-ref>
|
||||
```
|
||||
|
||||
The command never guesses or fetches a base. Supply the ref verified from current remote or stack state; use `--head <ref>` when inspecting a commit other than `HEAD`, and `--json` when another tool consumes the report. Its committed paths are relative to the resolved merge base, while staged, unstaged, and untracked paths describe the current worktree. After merging a changed base, rerun the report, reassess which behavior the combined scope can affect, and rerun only checks invalidated by the merge.
|
||||
The command never guesses or fetches a base. Supply the ref verified from current remote or stack state; use `--head <ref>` when inspecting a commit other than `HEAD`. Its versioned JSON records committed paths relative to the resolved merge base, while staged, unstaged, and untracked paths describe the current worktree. After merging a changed base, rerun the report, reassess which behavior the combined scope can affect, and rerun only checks invalidated by the merge.
|
||||
|
||||
## Select relevant evidence
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v6.3.0
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
--package sdk
|
||||
--output-dir dist-python
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v6.3.0
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
/tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default
|
||||
'
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.runtime.outputs.wheel }}
|
||||
path: dist-python/${{ steps.runtime.outputs.wheel }}
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -96,7 +96,7 @@ jobs:
|
||||
tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
|
||||
apps/*/lib packages/*/*/lib vendor/*/lib
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: node-24-built-tree
|
||||
path: ${{ runner.temp }}/node-24-built-tree.tar.gz
|
||||
@@ -323,7 +323,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v6.3.0
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
|
||||
6
.github/workflows/docs-pages.yml
vendored
6
.github/workflows/docs-pages.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Configure Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Verify and build documentation
|
||||
env:
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
run: pnpm run doc-sync
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: website/.dist
|
||||
|
||||
@@ -69,4 +69,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -29,4 +29,7 @@ python/**/.pytest_cache/
|
||||
apps/web/dist/
|
||||
.artifacts/
|
||||
.playwright-mcp/
|
||||
.orig
|
||||
.orig
|
||||
.worktrees/
|
||||
worktrees/
|
||||
.agents/worktrees/
|
||||
|
||||
@@ -160,7 +160,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn
|
||||
"text": "浏览器验收",
|
||||
},
|
||||
],
|
||||
"row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本",
|
||||
"row": "更新任务清单1/3 已完成 · 实现 fixture 样本",
|
||||
"rowState": "ok",
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -658,7 +658,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco
|
||||
'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts)
|
||||
Source: [`packages/client/ui-slash/src/types.ts:230`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
### `slash/input-consume-token` — bail
|
||||
|
||||
@@ -674,7 +674,7 @@ Consumes one command token after business success (popup settle / menu-pick exec
|
||||
'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts)
|
||||
Source: [`packages/client/ui-slash/src/types.ts:244`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
### `slash/input-insert-reference` — bail
|
||||
|
||||
@@ -690,7 +690,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr
|
||||
'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts)
|
||||
Source: [`packages/client/ui-slash/src/types.ts:237`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
### `slash/input-insert-text` — bail
|
||||
|
||||
@@ -707,7 +707,7 @@ Replaces the trigger token span with literal text — the plain-text reference p
|
||||
'slash/input-insert-text'(request: InsertTextRequest): true | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts)
|
||||
Source: [`packages/client/ui-slash/src/types.ts:252`](../../packages/client/ui-slash/src/types.ts)
|
||||
|
||||
## `subagent/*`
|
||||
|
||||
|
||||
@@ -1204,7 +1204,7 @@ Exact-read consumer that prepares immutable cross-session message context.
|
||||
/**
|
||||
* List reference candidates, ranked by working-directory affinity.
|
||||
* @param agent - target agent; self is excluded and its cwd drives ranking.
|
||||
* @param query - optional case-insensitive session-id/cwd substring.
|
||||
* @param query - optional case-insensitive session-id/cwd/title substring.
|
||||
* @param limit - optional positive result cap.
|
||||
* @param signal - optional cancellation boundary for host autocomplete teardown.
|
||||
* @returns candidates labeled by latest title or, when absent, session id.
|
||||
|
||||
@@ -35,10 +35,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:252`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
@@ -243,6 +243,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const children = logs.filter(log => typeof log.header.parentSession === 'string')
|
||||
.sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
|
||||
const actualSessions = [parent, ...children]
|
||||
const actualContext = contextFromLogs(actualSessions.map(log => log.content))
|
||||
if (refreshing) {
|
||||
const harvested = actualSessions.map((log): HarvestedLog => ({
|
||||
id: String(log.header.id),
|
||||
@@ -259,12 +260,11 @@ describe('headless stream-json snapshots', () => {
|
||||
if (existing === undefined || file === undefined) {
|
||||
throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
|
||||
}
|
||||
const stable = stabilizeRefreshLog(actual.content, existing, replacements)
|
||||
const stable = stabilizeRefreshLog(actual.content, existing, replacements, actualContext)
|
||||
await writeFile(file, stable)
|
||||
return stable
|
||||
}))
|
||||
}
|
||||
const actualContext = contextFromLogs(actualSessions.map(log => log.content))
|
||||
const expectedContext = contextFromLogs(expectedSessions)
|
||||
for (const [index, actual] of actualSessions.entries()) {
|
||||
const expected = expectedSessions[index]
|
||||
@@ -439,6 +439,7 @@ describe('headless stream-json snapshots', () => {
|
||||
expect(logs).toHaveLength(1)
|
||||
const actual = logs[0]
|
||||
if (actual === undefined) throw new Error('headless PTY snapshot did not persist its session')
|
||||
const actualContext = contextFromLogs([actual.content])
|
||||
if (refreshing) {
|
||||
const harvested: HarvestedLog = {
|
||||
id: String(actual.header.id),
|
||||
@@ -446,10 +447,9 @@ describe('headless stream-json snapshots', () => {
|
||||
content: actual.content,
|
||||
}
|
||||
const replacements = refreshFixtureReplacements([harvested], [expectedSession])
|
||||
expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements)
|
||||
expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext)
|
||||
await writeFile(ptySessionFixture, expectedSession)
|
||||
}
|
||||
const actualContext = contextFromLogs([actual.content])
|
||||
const expectedContext = contextFromLogs([expectedSession])
|
||||
expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
|
||||
.toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext)))
|
||||
|
||||
@@ -257,7 +257,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
|
||||
const existing = expectedContents[index]
|
||||
const file = files[index]
|
||||
if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`)
|
||||
const stable = stabilizeRefreshLog(log.content, existing, replacements)
|
||||
const stable = stabilizeRefreshLog(log.content, existing, replacements, actualContext)
|
||||
await writeFile(file, stable)
|
||||
return stable
|
||||
}))
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"node-addon-landlock-run": "workspace:*",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/node": "^26.0.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
28
native/landlock-run/pnpm-lock.yaml
generated
28
native/landlock-run/pnpm-lock.yaml
generated
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.10.0
|
||||
version: 24.13.2
|
||||
specifier: ^26.0.1
|
||||
version: 26.0.1
|
||||
node-addon-landlock-run:
|
||||
specifier: workspace:*
|
||||
version: link:packages/entry
|
||||
@@ -18,8 +18,8 @@ importers:
|
||||
specifier: ^4.20.6
|
||||
version: 4.23.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
|
||||
packages/entry:
|
||||
optionalDependencies:
|
||||
@@ -192,8 +192,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/node@24.13.2':
|
||||
resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==}
|
||||
'@types/node@26.0.1':
|
||||
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
|
||||
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
@@ -210,13 +210,13 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
@@ -298,9 +298,9 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@types/node@24.13.2':
|
||||
'@types/node@26.0.1':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
undici-types: 8.3.0
|
||||
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
@@ -340,6 +340,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
typescript@6.0.3: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
@@ -11,10 +11,21 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
|
||||
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
|
||||
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
|
||||
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
|
||||
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
|
||||
|
||||
## Reactive read and contract-currency discipline
|
||||
|
||||
How live data reaches render code, and what may cross a business boundary:
|
||||
|
||||
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
|
||||
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
|
||||
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
|
||||
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
|
||||
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
|
||||
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
|
||||
|
||||
## Export discipline (client plugin packages)
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499
|
||||
README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200
|
||||
README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
|
||||
README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5
|
||||
|
||||
@@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
|
||||
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
|
||||
|
||||
@@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
|
||||
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。
|
||||
|
||||
@@ -151,6 +151,13 @@ export class SessionsService {
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
/**
|
||||
* Atomic current-session provide projection: selection changes and
|
||||
* provider-roster changes publish through this one source (the renderer
|
||||
* host's `sessions.provide` feed), so a roster change under a stable
|
||||
* current id republishes the bundle instead of stranding mounted entries.
|
||||
*/
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
@@ -167,6 +174,10 @@ export class SessionsService {
|
||||
private readonly providers: SessionProvideDescriptor[] = []
|
||||
/** Static no-session projection, rebuilt only when the provider roster changes. */
|
||||
private maybeInfo: SessionMaybeProvideInfo
|
||||
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
|
||||
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
|
||||
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
|
||||
private readonly currentProvideInfoListeners = new Set<() => void>()
|
||||
/**
|
||||
* The staged session id — follows `list.current` exactly, holding its last
|
||||
* defined value across masked gaps (a transiently absent selection blanks
|
||||
@@ -198,7 +209,11 @@ export class SessionsService {
|
||||
// dedicated code path. Safe to run synchronously inside the store notify:
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
// The current-provide projection follows the same current writes.
|
||||
this.list.subscribe(() => {
|
||||
this.followCurrent()
|
||||
this.updateCurrentProvideInfo()
|
||||
})
|
||||
// The runtime's own contribution comes first: useSession rides the same
|
||||
// provide channel every plugin uses (no renderer special case).
|
||||
this.providers.push({
|
||||
@@ -206,6 +221,14 @@ export class SessionsService {
|
||||
resolve: binding => ({ hooks: { session: binding.session } }),
|
||||
})
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
this.currentProvideInfoSnapshot = this.maybeInfo
|
||||
this.currentProvideInfo = {
|
||||
getSnapshot: () => this.currentProvideInfoSnapshot,
|
||||
subscribe: (fn) => {
|
||||
this.currentProvideInfoListeners.add(fn)
|
||||
return () => { this.currentProvideInfoListeners.delete(fn) }
|
||||
},
|
||||
}
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
@@ -238,6 +261,30 @@ export class SessionsService {
|
||||
for (const record of this.scopes.values()) {
|
||||
record.provideInfo = this.materializeProvideInfo(record.binding)
|
||||
}
|
||||
this.updateCurrentProvideInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the current selection's provide bundle and publish it when it
|
||||
* changed. Bundles are identity-stable per (scope, roster)
|
||||
* materialization, so an identity compare is exact; synchronous notify —
|
||||
* both call sites (list.subscribe, provide()) already sit behind their own
|
||||
* batching or registration edges.
|
||||
*/
|
||||
private updateCurrentProvideInfo(): void {
|
||||
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
|
||||
if (next === this.currentProvideInfoSnapshot) return
|
||||
this.currentProvideInfoSnapshot = next
|
||||
for (const fn of [...this.currentProvideInfoListeners]) {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
// Contain subscriber failures: this notify runs inside the list
|
||||
// notification, where a throwing render-side subscriber would starve
|
||||
// later listeners and abort the projection pass that scheduled it.
|
||||
console.error('sessions.currentProvideInfo subscriber failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the static no-session kit and reject duplicate declared names. */
|
||||
@@ -404,25 +451,21 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer standard-props bundle (SessionProvider's feed
|
||||
* through the renderer host; ctx never enters the render layer). Pure
|
||||
* resolution — render-safe: SessionProvider calls this during render, so no
|
||||
* staging, no window side effects (StrictMode double-invokes and concurrent
|
||||
* discarded passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns the provide info, or undefined for a session neither listed nor already scoped.
|
||||
* Resolve one session's render-layer standard-props bundle (ctx never
|
||||
* enters the render layer; the renderer subscribes to
|
||||
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
|
||||
* no staging, no window side effects (StrictMode double-invokes and
|
||||
* concurrent discarded passes must stay free).
|
||||
*/
|
||||
provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
private provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
return this.resolve(id as SessionId)?.provideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current-session-optional standard kit. Unknown or absent ids
|
||||
* return the static no-session projection rather than removing hook props.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns a definite or no-session provide bundle.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
||||
}
|
||||
|
||||
|
||||
@@ -246,13 +246,6 @@ export class SlotsService extends Service {
|
||||
if (workspaces === undefined) {
|
||||
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
|
||||
}
|
||||
// Identity-stable view: current rides the list snapshot (arbitrated), but
|
||||
// the provider consumes it as its own observable; one cached object keeps
|
||||
// the renderer's per-source hook cache stable.
|
||||
const current = {
|
||||
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
|
||||
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
|
||||
}
|
||||
this._host = {
|
||||
subscribe: (key, fn) => this._core.subscribe(key, fn),
|
||||
getVersion: key => this._core.getVersion(key),
|
||||
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
|
||||
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
|
||||
sessions: {
|
||||
list: sessions.list,
|
||||
current,
|
||||
provideInfo: id => sessions.provideInfo(id),
|
||||
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
|
||||
provideInfo: sessions.currentProvideInfo,
|
||||
},
|
||||
workspaces: { list: workspaces.list },
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ describe('scope tree', () => {
|
||||
expect(scopeOf(scoped as Context)).toBe('s1')
|
||||
expect(scopeOf(b.ctx)).toBeUndefined()
|
||||
const binding = b.svc.binding(sid('s1'))
|
||||
expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
|
||||
b.svc.open(sid('s1'))
|
||||
expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
|
||||
expect(b.svc.binding(sid('s1'))).toBe(binding)
|
||||
expect(binding?.ctx).toBe(scoped)
|
||||
})
|
||||
@@ -183,24 +184,82 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
|
||||
})
|
||||
|
||||
describe('cell (render-layer session kit)', () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
|
||||
it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const info = b.svc.provideInfo('s1')
|
||||
expect(info).toBeDefined()
|
||||
expect(info?.sessionId).toBe('s1')
|
||||
b.svc.open(sid('s1'))
|
||||
const info = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(info.sessionId).toBe('s1')
|
||||
// The bundle carries bare observables; hook binding happens in React.
|
||||
expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(b.svc.provideInfo('s1')).toBe(info)
|
||||
expect(b.svc.provideInfo('ghost')).toBeUndefined()
|
||||
expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
// Re-staging the same id republishes nothing: identity holds.
|
||||
b.svc.open(sid('s1'))
|
||||
expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
|
||||
})
|
||||
|
||||
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
|
||||
it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
const absent = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(absent.sessionId).toBeUndefined()
|
||||
expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
|
||||
const notified = vi.fn()
|
||||
b.svc.currentProvideInfo.subscribe(notified)
|
||||
b.svc.open(sid('s1'))
|
||||
const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(s1Bundle.sessionId).toBe('s1')
|
||||
expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
|
||||
expect(notified).toHaveBeenCalledTimes(1)
|
||||
b.svc.open(sid('s2'))
|
||||
const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(s2Bundle.sessionId).toBe('s2')
|
||||
expect(s2Bundle).not.toBe(s1Bundle)
|
||||
expect(notified).toHaveBeenCalledTimes(2)
|
||||
b.svc.clear()
|
||||
await Promise.resolve() // clearSelection projects through the manager notifier
|
||||
expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a provider roster change under a stable current id republishes the bundle', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
const before = b.svc.currentProvideInfo.getSnapshot()
|
||||
const notified = vi.fn()
|
||||
b.svc.currentProvideInfo.subscribe(notified)
|
||||
const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
|
||||
const dispose = b.svc.provide({
|
||||
hooks: ['extra'],
|
||||
props: ['marker'],
|
||||
resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
|
||||
})
|
||||
const added = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(added).not.toBe(before)
|
||||
expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
|
||||
expect(added.hooks['extra']).toBe(source)
|
||||
expect(notified).toHaveBeenCalledTimes(1)
|
||||
dispose()
|
||||
const removed = b.svc.currentProvideInfo.getSnapshot()
|
||||
expect(removed).not.toBe(added)
|
||||
expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
|
||||
expect(notified).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
const notified = vi.fn()
|
||||
const off = b.svc.currentProvideInfo.subscribe(notified)
|
||||
off()
|
||||
b.svc.open(sid('s1'))
|
||||
expect(notified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binding() is pure resolution: no staging, no deferred sweep', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
b.svc.open(sid('s1')) // staged
|
||||
b.svc.provideInfo('s2') // resolution only — must NOT move the stage
|
||||
b.svc.binding(sid('s2'))
|
||||
b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
|
||||
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
|
||||
expect(b.svc.scope(sid('s1'))).toBeDefined()
|
||||
})
|
||||
@@ -211,7 +270,6 @@ describe('cell (render-layer session kit)', () => {
|
||||
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
|
||||
// Resolution is addressing, not staging: no window pull.
|
||||
b.svc.scope(sid('s1'))
|
||||
b.svc.provideInfo('s1')
|
||||
b.svc.binding(sid('s1'))
|
||||
expect(historyCalls()).toHaveLength(0)
|
||||
b.svc.open(sid('s1'))
|
||||
|
||||
@@ -97,18 +97,13 @@ function fakeWorkspaces() {
|
||||
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
|
||||
}
|
||||
|
||||
/** Minimal sessions face for the host seam (list observable + provide bundle). */
|
||||
/** Minimal sessions face for the host seam (list observable + current provide projection). */
|
||||
function fakeSessions() {
|
||||
const state = { ids: [], byId: {}, current: undefined as string | undefined }
|
||||
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
|
||||
return {
|
||||
list: { getSnapshot: () => state, subscribe: () => () => undefined },
|
||||
provideInfo: (id: string) => (id === 'known'
|
||||
? {
|
||||
sessionId: id,
|
||||
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
|
||||
props: {},
|
||||
}
|
||||
: undefined),
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,13 +227,11 @@ describe('host face', () => {
|
||||
expect(host.entriesOf('t.host')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
|
||||
it('exposes the session list and the atomic current provide projection', async () => {
|
||||
const bench = await boot()
|
||||
const host = captureHost(bench)
|
||||
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
|
||||
expect(host.sessions.current.getSnapshot()).toBeUndefined()
|
||||
expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
|
||||
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
|
||||
expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
|
||||
})
|
||||
|
||||
it('exposes the independent Workspace list source', async () => {
|
||||
|
||||
@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
inject: (sessionId: SessionId): ComposerBarInjected => {
|
||||
const shell = inputHub.shell(sessionId)
|
||||
return {
|
||||
keyboard: inputHub.keyboard(sessionId),
|
||||
keyboard: shell,
|
||||
stop: () => {
|
||||
scopedConversation(sessions, sessionId).cancel().catch(() => {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
hooks: { notices: shell.notices, lexicon: shell.lexicon },
|
||||
}
|
||||
},
|
||||
}, InputBar)
|
||||
|
||||
@@ -40,13 +40,20 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
// a node that is only those heads (or empty) would paint an empty root
|
||||
// between tool groups — skip the shell unless something visible remains.
|
||||
const hasVisible = streaming
|
||||
|| interrupted === true
|
||||
|| blocks.some(block => block.kind !== 'tool-call')
|
||||
if (!hasVisible) return null
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass.
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Conversation slot declarations and their composed component props. */
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
|
||||
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
@@ -222,6 +222,13 @@ export interface ComposerBarInjected {
|
||||
keyboard: ComposerKeyboard
|
||||
/** Cancel the in-flight turn. */
|
||||
stop: () => void
|
||||
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
notices: ObservableSnapshot<InputNotice | null>
|
||||
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
|
||||
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,11 +240,11 @@ export interface InputControlOwnerProps {
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
|
||||
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
|
||||
export type ComposerBarProps =
|
||||
PropsRuntime<'conversation.composer.bar'>
|
||||
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
|
||||
& ComposerBarInjected
|
||||
& InjectFace<ComposerBarInjected>
|
||||
|
||||
/**
|
||||
* Composer chain currency: what ConversationRoot dispatches at its
|
||||
|
||||
@@ -77,8 +77,6 @@ export interface InputNotice {
|
||||
* satisfies it structurally.
|
||||
*/
|
||||
export interface ComposerKeyboard {
|
||||
/** Latest surfaced notice store (null after none). */
|
||||
readonly notices: SnapshotStore<InputNotice | null>
|
||||
/** Live machine state for event-handler reads (render reads go through useInput). */
|
||||
readonly snapshot: InputState
|
||||
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
|
||||
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
|
||||
space(): boolean
|
||||
/** Dismiss the popupSelect shell (any interaction outside the box). */
|
||||
dismissPopup(): void
|
||||
/** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */
|
||||
lexicon(): ReadonlyMap<'/' | '@', readonly string[]>
|
||||
}
|
||||
|
||||
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
|
||||
|
||||
@@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot plain-text reference lexicons for the decoration scan (decision 21).
|
||||
* @returns the controller's per-trigger aggregation; empty Map without a pipeline.
|
||||
* Hot plain-text reference lexicon source for the decoration scan
|
||||
* (decision 21): delegates to the controller's aggregated store. Stable
|
||||
* identity per shell; without a pipeline the snapshot is the empty Map and
|
||||
* subscribers never fire.
|
||||
*/
|
||||
lexicon(): ReadonlyMap<'/' | '@', readonly string[]> {
|
||||
return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON
|
||||
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
|
||||
getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
|
||||
subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/** The default composer body: the 'conversation.composer.bar' slot entry
|
||||
* (decision 20). Machine state arrives through the standard provide channel
|
||||
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
|
||||
* through this entry's own inject; layout-phase inputs (variant, placeholder,
|
||||
* through this entry's own inject, whose hooks compartment binds
|
||||
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
|
||||
* region-slot content) ride the owner props. Session facts
|
||||
* (running/removed/promptError) are self-selected via useSession. */
|
||||
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -27,15 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, renderSlot,
|
||||
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const noticeStore = keyboard.notices
|
||||
const notice = useSyncExternalStore(
|
||||
(fn: () => void) => noticeStore.subscribe(fn),
|
||||
() => noticeStore.getSnapshot(),
|
||||
)
|
||||
const notice = useNotices(s => s)
|
||||
const lexicon = useLexicon(s => s)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const running = useSession(s => s.running)
|
||||
const disabled = useSession(s => s.removed)
|
||||
@@ -244,7 +242,7 @@ export function InputBar({
|
||||
// claim token highlights through behind the textarea glyphs; each U+FFFC
|
||||
// placeholder renders as a chip (the textarea's own glyph is invisible, the
|
||||
// backdrop chip supplies the visual); the claim hint is ghost text.
|
||||
const deco = deriveDecorations(input, keyboard.lexicon())
|
||||
const deco = deriveDecorations(input, lexicon)
|
||||
const backdrop: ReactNode[] = []
|
||||
{
|
||||
// Segment boundaries: the token range end, every chip offset, and every
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
/* todo_write plan-update row: title + progress summary on one line. */
|
||||
/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
|
||||
[16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
.leading {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500; /* figma wt510, rendered 500 */
|
||||
color: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -28,11 +44,15 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.err {
|
||||
flex: none;
|
||||
margin-left: 8px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
// hole like the bash sample (a product registration, not a sample). The row
|
||||
// summarizes the written list (counts + active item) from the call args; the
|
||||
// durable list itself renders in the TodoPanel above the composer, so the
|
||||
// row stays one line.
|
||||
// row stays one line. Chrome matches ToolRow (figma 780:53675).
|
||||
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './todo-row.module.css'
|
||||
|
||||
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
|
||||
@@ -40,6 +40,17 @@ function summarize(argsRaw: string): string | null {
|
||||
: head
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
|
||||
* state semantic while running or failed; ok keeps the checklist glyph. */
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'running': return <StateDot state="ongoing" />
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconChecklistOutline16 />
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line plan update row (click opens the raw args in details). Non-ok
|
||||
* execution states keep the generic row's dot semantics — a cancelled call
|
||||
* wrote no todo/write, so it must not read as a completed update. */
|
||||
@@ -64,10 +75,9 @@ export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
|
||||
onClick={openDetails}
|
||||
onKeyDown={openFromKeyboard}
|
||||
>
|
||||
{model.state === 'ok'
|
||||
? <span className={css.badge} aria-hidden>☰</span>
|
||||
: <StateDot state={model.state === 'running' ? 'ongoing' : model.state === 'stopped' ? 'warning' : 'error'} />}
|
||||
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
|
||||
<span className={css.title}>更新任务清单</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{summary}</span>
|
||||
{model.state === 'error' && <span className={css.err}>failed</span>}
|
||||
{model.state === 'stopped' && <span className={css.err}>已中断</span>}
|
||||
|
||||
@@ -87,12 +87,13 @@ async function bench() {
|
||||
}
|
||||
}
|
||||
const providers: TestProvider[] = []
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
|
||||
scope: (id: SessionId) => mint(id),
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
|
||||
scopeOf,
|
||||
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
|
||||
|
||||
@@ -32,12 +32,13 @@ async function bench() {
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: vi.fn(),
|
||||
scope: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
provide: vi.fn(() => () => {}),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
|
||||
@@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
// Provide-channel contributions land in this bundle the way the runtime
|
||||
// materializes them; the renderer host serves it through provideInfo.
|
||||
const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
|
||||
// Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract),
|
||||
// materialized on first render after the provide contributions landed.
|
||||
let infoCell: { sessionId: SessionId; hooks: Record<string, unknown>; props: Record<string, unknown> } | undefined
|
||||
const sessionsFake = {
|
||||
list,
|
||||
binding: (id: SessionId) => (id === SID
|
||||
@@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
provideInfo: (id: string) => (id === SID
|
||||
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
|
||||
: undefined),
|
||||
maybeProvideInfo: (id: string | undefined) => (id === SID
|
||||
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
|
||||
: { hooks: provided.hooks, props: provided.props }),
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props },
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
|
||||
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
|
||||
|
||||
afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear between cases.
|
||||
beforeEach(() => {
|
||||
@@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
subscribe: (fn: () => void) => session.subscribe(fn),
|
||||
},
|
||||
})
|
||||
const provideInfo = (id: string) => {
|
||||
if (id !== SID) return undefined
|
||||
if (info === undefined) {
|
||||
const hooks: Record<string, unknown> = { session }
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const provider of providers) {
|
||||
const c = provider(bindingOf(SID))
|
||||
Object.assign(hooks, c.hooks ?? {})
|
||||
Object.assign(props, c.props ?? {})
|
||||
}
|
||||
info = { sessionId: SID, hooks, props }
|
||||
}
|
||||
return info
|
||||
}
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
binding: bindingOf,
|
||||
scope: () => actxFake,
|
||||
provideInfo: (id: string) => {
|
||||
if (id !== SID) return undefined
|
||||
if (info === undefined) {
|
||||
const hooks: Record<string, unknown> = { session }
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const provider of providers) {
|
||||
const c = provider(bindingOf(SID))
|
||||
Object.assign(hooks, c.hooks ?? {})
|
||||
Object.assign(props, c.props ?? {})
|
||||
}
|
||||
info = { sessionId: SID, hooks, props }
|
||||
}
|
||||
return info
|
||||
},
|
||||
maybeProvideInfo(id: string | undefined) {
|
||||
// `this` inside an object-literal method is any under strict lint; the
|
||||
// fake resolves through its own provideInfo above.
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
||||
provideInfo,
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => provideInfo(SID),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||
scopeOf: () => SID,
|
||||
@@ -254,7 +255,10 @@ describe('registrant load-order seam', () => {
|
||||
binding: () => undefined,
|
||||
scope: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => ABSENT_INFO,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: () => () => {},
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
|
||||
@@ -60,6 +60,20 @@ describe('tails', () => {
|
||||
expect(stopped.getByText('已停止')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('AssistantMarkdown skips the root shell when only tool-call heads remain', () => {
|
||||
// Tool heads are drawn by ChatView's tool groups; an empty root between
|
||||
// groups is layout noise (no text, no pulse, no interrupted marker).
|
||||
const empty = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]}
|
||||
streaming={false}
|
||||
/>,
|
||||
)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const blank = render(<AssistantMarkdown blocks={[]} streaming={false} />)
|
||||
expect(blank.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
|
||||
const settled: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
|
||||
|
||||
@@ -56,7 +56,11 @@ function bench(over?: BenchOptions) {
|
||||
// Lexicon-only stub: adjudication untouched (undefined slash methods are
|
||||
// never reached — these benches drive plain-draft flows only).
|
||||
...(lex !== undefined
|
||||
? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable<ShellDeps['slash']> }
|
||||
? {
|
||||
slash: (() => ({
|
||||
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
|
||||
})) as unknown as NonNullable<ShellDeps['slash']>,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
|
||||
@@ -87,6 +91,8 @@ function bench(over?: BenchOptions) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
stop,
|
||||
renderSlot,
|
||||
variant: over?.variant ?? 'composer',
|
||||
|
||||
@@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
variant: 'composer',
|
||||
|
||||
@@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
useInput: bindSnapshotSelector(shell.state),
|
||||
inputActions: shell.actions,
|
||||
keyboard: shell,
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
variant: 'composer',
|
||||
@@ -234,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('scenario: reference decoration lights up when the lexicon settles', () => {
|
||||
it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => {
|
||||
let roll: readonly string[] | undefined
|
||||
let notify: (() => void) | undefined
|
||||
const b = await scopedBench((slash) => {
|
||||
slash.registerSource({
|
||||
trigger: '/', name: 'skill',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
lexicon: () => roll,
|
||||
subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => {
|
||||
notify = listener
|
||||
return () => { notify = undefined }
|
||||
},
|
||||
} as never)
|
||||
})
|
||||
// Typed before the catalog settled: a plain token, no decoration.
|
||||
b.type('/deploy now')
|
||||
expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
|
||||
// The catalog settles (ui-skill's settle path fires the same notification).
|
||||
act(() => {
|
||||
roll = ['deploy']
|
||||
notify?.()
|
||||
})
|
||||
const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
|
||||
expect(mark?.textContent).toBe('/deploy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scenario I: unknown /xyz + enter', () => {
|
||||
it('adjudication misses in one hop and the whole line rides the default sink', async () => {
|
||||
const b = await bench()
|
||||
|
||||
@@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
|
||||
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
|
||||
|
||||
interface Bench {
|
||||
slots: SlotsService
|
||||
chat: ReturnType<typeof createChatStore>
|
||||
@@ -23,7 +26,10 @@ function bench(): Bench {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}),
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
currentProvideInfo: {
|
||||
getSnapshot: () => ABSENT_INFO,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
provide: () => () => {},
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
|
||||
@@ -118,6 +118,8 @@ function mount(
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
|
||||
@@ -653,6 +653,16 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_checklist_outline_16 (figma extract): two rings + two list bars. */
|
||||
export const IconChecklistOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(1.736 2.0752)" d="M12.5279 8.64648V9.92617H6.48105V8.64648H12.5279Z" fill="currentColor" />
|
||||
<path transform="translate(1.736 2.0752)" d="M12.5279 1.92275V3.20244H6.48105V1.92275H12.5279Z" fill="currentColor" />
|
||||
<path transform="translate(1.736 2.0752)" d="M3.84531 9.28623C3.84525 8.57774 3.271 8.00342 2.5625 8.00342C1.85405 8.00348 1.27975 8.57778 1.27969 9.28623C1.27969 9.99474 1.85401 10.569 2.5625 10.569C3.27105 10.569 3.84531 9.99478 3.84531 9.28623ZM5.12578 9.28623C5.12578 10.7017 3.97797 11.8495 2.5625 11.8495C1.14709 11.8494 0 10.7017 0 9.28623C6.59755e-05 7.87086 1.14713 6.7238 2.5625 6.72373C3.97793 6.72373 5.12572 7.87082 5.12578 9.28623Z" fill="currentColor" />
|
||||
<path transform="translate(1.736 2.0752)" d="M3.84551 2.5625C3.84549 1.85402 3.27118 1.27969 2.5627 1.27969C1.85422 1.2797 1.2799 1.85403 1.27988 2.5625C1.27988 3.27098 1.85422 3.8453 2.5627 3.84531C3.27119 3.84531 3.84551 3.27099 3.84551 2.5625ZM5.1252 2.5625C5.1252 3.97792 3.97811 5.125 2.5627 5.125C1.14729 5.12499 0.000195313 3.97791 0.000195313 2.5625C0.000208508 1.1471 1.1473 1.31957e-05 2.5627 0C3.9781 0 5.12518 1.1471 5.1252 2.5625Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_List_Pen_outline_16 */
|
||||
export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full P-I set (43 deepsuite + 12 figma extracts)', () => {
|
||||
expect(iconNames.length).toBe(55)
|
||||
it('exports the full P-I set (43 deepsuite + 13 figma extracts)', () => {
|
||||
expect(iconNames.length).toBe(56)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps } from './contract/slots.ts'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
||||
import css from './SettingsRoot.module.css'
|
||||
|
||||
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
|
||||
@@ -20,7 +20,7 @@ function navIcon(id: string) {
|
||||
}
|
||||
|
||||
type PanelProps = {
|
||||
rows: ReturnType<SettingsRootComponentProps['sections']>
|
||||
rows: readonly SettingsSectionRow[]
|
||||
renderSlot: SettingsRootComponentProps['renderSlot']
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -92,20 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
|
||||
* @returns the settings shell element tree.
|
||||
*/
|
||||
export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
|
||||
const { wide, useSections, renderSlot } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
|
||||
// The ledger tick keeps the nav rows fresh: registrants re-register with
|
||||
// freshly localized text on locale change, and the trigger/header/close
|
||||
// seats re-render through their own outlets' subscriptions.
|
||||
// State = ledger version: same-version notifications dedupe to no render.
|
||||
const [, setSectionsRev] = useState(() => sectionsVersion())
|
||||
useEffect(
|
||||
() => subscribeSections(() => { setSectionsRev(sectionsVersion()) }),
|
||||
[subscribeSections, sectionsVersion],
|
||||
)
|
||||
const rows = sections()
|
||||
const rows = useSections(s => s)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* setting never means editing the shell; copy that belongs to no single
|
||||
* feature (chrome, the General section) is owned by ui-settings-general.
|
||||
*/
|
||||
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
|
||||
// into every program that sees this contract.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
@@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps {
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** One nav row projected from a settings.section registration's options. */
|
||||
export interface SettingsSectionRow {
|
||||
id: string
|
||||
order: number
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the settings shell (assembled in
|
||||
* apply): ledger projections only — the shell reads no locale state.
|
||||
* apply): the ledger's nav-row projection as a hooks-compartment source —
|
||||
* the shell reads no locale state and subscribes through the bound hook.
|
||||
*/
|
||||
export type SettingsRootInjected = {
|
||||
/** Read the settings.section ledger version (nav invalidation). */
|
||||
sectionsVersion: () => number
|
||||
/** Subscribe to settings.section ledger changes. */
|
||||
subscribeSections: (listener: () => void) => () => void
|
||||
/** Project the settings.section ledger into nav rows (id/order/label). */
|
||||
sections: () => readonly { id: string; order: number; label: string }[]
|
||||
hooks: {
|
||||
/** settings.section ledger projected into ordered nav rows. */
|
||||
sections: HostObservable<readonly SettingsSectionRow[]>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component props of the settings shell root: the sidebar owner share
|
||||
* (wide/rail state) plus the declared render shares and the injected face.
|
||||
* No store is registered — modal open state and active section id are
|
||||
* component-local viewing state.
|
||||
* (wide/rail state) plus the declared render shares and the injected face
|
||||
* (hooks compartment bound to useSections). No store is registered — modal
|
||||
* open state and active section id are component-local viewing state.
|
||||
*/
|
||||
export type SettingsRootComponentProps =
|
||||
PropsRuntime<'sidebar.settings'>
|
||||
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
|
||||
& SettingsRootInjected
|
||||
& InjectFace<SettingsRootInjected>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SettingsRootInjected } from './contract/slots.ts'
|
||||
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
|
||||
export type {
|
||||
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
|
||||
SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
|
||||
SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
@@ -32,17 +32,31 @@ export const inject = ['slots']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
// Ledger → nav-row projection as an observable source (uSES contract:
|
||||
// getSnapshot returns the cached rows until the ledger version moves).
|
||||
let rowsVersion = -1
|
||||
let rows: readonly SettingsSectionRow[] = []
|
||||
const injected = (): SettingsRootInjected => ({
|
||||
sectionsVersion: () => ctx.slots.getVersion('settings.section'),
|
||||
subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
|
||||
sections: () => ctx.slots.entries('settings.section')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
label: e.options.label ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order),
|
||||
hooks: {
|
||||
sections: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.section')
|
||||
if (version !== rowsVersion) {
|
||||
rowsVersion = version
|
||||
rows = ctx.slots.entries('settings.section')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
label: e.options.label ?? '',
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return rows
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.section', listener),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
|
||||
|
||||
@@ -60,22 +60,25 @@ describe('ui-settings apply', () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(b.slots)
|
||||
const { sections } = injectedOf(b.slots).hooks
|
||||
// The shell ships no sections of its own — registrants fill the ledger.
|
||||
expect(injected.sections()).toEqual([])
|
||||
expect(sections.getSnapshot()).toEqual([])
|
||||
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
|
||||
// No order and no label: both projection defaults apply.
|
||||
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
|
||||
expect(injected.sections()).toEqual([
|
||||
const rows = sections.getSnapshot()
|
||||
expect(rows).toEqual([
|
||||
{ id: 'a', order: 0, label: '' },
|
||||
{ id: 'z', order: 20, label: 'Z' },
|
||||
])
|
||||
expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
|
||||
// Snapshot identity is stable until the ledger moves (uSES contract).
|
||||
expect(sections.getSnapshot()).toBe(rows)
|
||||
const listener = vi.fn()
|
||||
const off = injected.subscribeSections(listener)
|
||||
const off = sections.subscribe(listener)
|
||||
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalled()
|
||||
expect(sections.getSnapshot()).not.toBe(rows)
|
||||
off()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
|
||||
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
@@ -22,9 +23,9 @@ function mount({
|
||||
{ id: 'models', order: 10, label: 'Models' },
|
||||
],
|
||||
}: { wide?: boolean; rows?: Row[] } = {}) {
|
||||
// Mutable row store standing in for the ledger; bump() plays a change.
|
||||
// Mutable row source standing in for the bound useSections hook; bump()
|
||||
// plays a ledger change through the same observable contract.
|
||||
let current = rows
|
||||
let version = 0
|
||||
const listeners = new Set<() => void>()
|
||||
const renderSlot = vi.fn(
|
||||
((key: string, _owner: unknown, opts?: { only?: string }) => {
|
||||
@@ -38,19 +39,21 @@ function mount({
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
wide,
|
||||
sectionsVersion: () => version,
|
||||
subscribeSections: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
useSections: (select) => {
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
const listener = () => { force(n => n + 1) }
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}, [])
|
||||
return select(current)
|
||||
},
|
||||
sections: () => current,
|
||||
renderSlot,
|
||||
}
|
||||
const view = render(<SettingsRoot {...props} />)
|
||||
const bump = (next: Row[]) => {
|
||||
act(() => {
|
||||
current = next
|
||||
version += 1
|
||||
for (const fn of [...listeners]) fn()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,6 +44,21 @@ export function apply(ctx: ClientContext): void {
|
||||
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
|
||||
// the fiber effect below is its teardown boundary.
|
||||
const fetches = new Map<SessionId, CatalogFetch>()
|
||||
// Per-session lexicon invalidation listeners (subscribeLexicon consumers).
|
||||
const lexiconListeners = new Map<SessionId, Set<() => void>>()
|
||||
|
||||
const notifyLexicon = (sessionId: SessionId): void => {
|
||||
for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
// Contain listener failures: settlement notifies from an ignored
|
||||
// promise chain (a throw would surface as an unhandled rejection)
|
||||
// and one faulty consumer must not starve the others.
|
||||
console.error('[ui-skill] lexicon listener failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
|
||||
const existing = fetches.get(sessionId)
|
||||
@@ -58,7 +73,10 @@ export function apply(ctx: ClientContext): void {
|
||||
fetches.set(sessionId, entry)
|
||||
promise.then(
|
||||
// Settled snapshot backs the synchronous lexicon reads.
|
||||
(skills) => { entry.settled = skills },
|
||||
(skills) => {
|
||||
entry.settled = skills
|
||||
notifyLexicon(sessionId)
|
||||
},
|
||||
// A failed fetch must not poison the key: the next consumer retries.
|
||||
() => {
|
||||
if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
|
||||
@@ -72,6 +90,7 @@ export function apply(ctx: ClientContext): void {
|
||||
if (entry === undefined) return
|
||||
fetches.delete(key)
|
||||
entry.abort.abort()
|
||||
notifyLexicon(key)
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
@@ -97,6 +116,16 @@ export function apply(ctx: ClientContext): void {
|
||||
lexicon(session) {
|
||||
return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
|
||||
},
|
||||
subscribeLexicon(session, listener) {
|
||||
const key = session.sessionId
|
||||
const listeners = lexiconListeners.get(key) ?? new Set()
|
||||
listeners.add(listener)
|
||||
lexiconListeners.set(key, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0) lexiconListeners.delete(key)
|
||||
}
|
||||
},
|
||||
onPick({ candidate }) {
|
||||
// Decision 21: plain-text reference — the literal lands in the draft
|
||||
// and ships to the model verbatim (trailing space closes the token).
|
||||
|
||||
@@ -208,6 +208,33 @@ describe('lexicon', () => {
|
||||
// Another session's key is independent — cold until its own fetch.
|
||||
expect(source.lexicon!(proj('s2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => {
|
||||
const { list } = countingList()
|
||||
const { ctx, source } = await bench(list)
|
||||
const s1 = vi.fn()
|
||||
const s2 = vi.fn()
|
||||
source.subscribeLexicon!(proj('s1'), s1)
|
||||
source.subscribeLexicon!(proj('s2'), s2)
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
expect(s1).toHaveBeenCalledTimes(1)
|
||||
expect(s2).not.toHaveBeenCalled()
|
||||
// Reset invalidates every cached session: each key notifies its own listeners.
|
||||
await source.candidates(proj('s2'), req(''))
|
||||
ctx.emit('connection/reset')
|
||||
expect(s1).toHaveBeenCalledTimes(2)
|
||||
expect(s2).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('an unsubscribed lexicon listener stops receiving notifications', async () => {
|
||||
const { list } = countingList()
|
||||
const { source } = await bench(list)
|
||||
const listener = vi.fn()
|
||||
const off = source.subscribeLexicon!(proj('s1'), listener)
|
||||
off()
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick and codec', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a
|
||||
README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
|
||||
README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
|
||||
README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
|
||||
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
|
||||
|
||||
Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
|
||||
输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
|
||||
|
||||
分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
|
||||
|
||||
|
||||
@@ -40,18 +40,35 @@ export interface SlashControllerDeps {
|
||||
export class SlashController {
|
||||
/** Menu state store (per-session; survives session switches, dies with the scope). */
|
||||
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
|
||||
/**
|
||||
* Aggregated hot reference lexicon, grouped by trigger (decision 21):
|
||||
* sources implementing the lexicon hook are polled with the session
|
||||
* projection; undefined answers (roll not hot yet) are skipped; multiple
|
||||
* sources on one trigger concatenate in registration order. A snapshot
|
||||
* store because rolls change asynchronously (catalog settles, children
|
||||
* spawn/exit) — render-side consumers subscribe instead of re-reading a
|
||||
* mutable answer.
|
||||
*/
|
||||
readonly lexicon: SnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>> =
|
||||
createSnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>>(new Map())
|
||||
|
||||
/** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
|
||||
private hit: TriggerHit | null = null
|
||||
private fetch: AbortController | null = null
|
||||
private disposed = false
|
||||
/** Per-source lexicon unsubscribers (sources without the hook never enter). */
|
||||
private readonly lexiconOffs = new Map<SlashSource, () => void>()
|
||||
|
||||
constructor(private readonly deps: SlashControllerDeps) {
|
||||
// Scope-birth prewarm: sessions are always agent-backed, so the one-time
|
||||
// roster warm here replaces the projection-transition watch — there are
|
||||
// no capability steps to react to.
|
||||
const projection = this.project()
|
||||
for (const src of deps.roster.all()) src.warm?.(projection)
|
||||
for (const src of deps.roster.all()) {
|
||||
src.warm?.(projection)
|
||||
this.watchLexicon(src, projection)
|
||||
}
|
||||
this.refreshLexicon()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,6 +237,23 @@ export class SlashController {
|
||||
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
|
||||
this.reduce({ type: 'source-failed', generation: state.generation, source: source.name })
|
||||
}
|
||||
this.lexiconOffs.get(source)?.()
|
||||
this.lexiconOffs.delete(source)
|
||||
this.refreshLexicon()
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit a source registered after this controller's birth (root registry
|
||||
* change notification): warm it and fold its roll into the live lexicon —
|
||||
* the constructor-time prewarm covers only the roster present at scope
|
||||
* birth.
|
||||
* @param source - the newly registered source.
|
||||
*/
|
||||
sourceAdded(source: SlashSource): void {
|
||||
const projection = this.project()
|
||||
source.warm?.(projection)
|
||||
this.watchLexicon(source, projection)
|
||||
this.refreshLexicon()
|
||||
}
|
||||
|
||||
/** Scope teardown: close and abort (the service deletes the map entry). */
|
||||
@@ -228,6 +262,8 @@ export class SlashController {
|
||||
this.stopFetch()
|
||||
this.reduce({ type: 'close' })
|
||||
this.hit = null
|
||||
for (const off of this.lexiconOffs.values()) off()
|
||||
this.lexiconOffs.clear()
|
||||
}
|
||||
|
||||
/** The session projection handed to sources (agent-backed identity; constant per scope). */
|
||||
@@ -248,25 +284,33 @@ export class SlashController {
|
||||
return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the sources' plain-text reference lexicons (decision 21),
|
||||
* grouped by trigger: sources implementing the hook are polled with the
|
||||
* session projection (onSpace's poll pattern); undefined answers (roll not
|
||||
* hot yet) are skipped; multiple sources on one trigger concatenate in
|
||||
* registration order.
|
||||
* @returns trigger → decorated-name roll for the decoration scan.
|
||||
*/
|
||||
lexicon(): ReadonlyMap<TriggerChar, readonly string[]> {
|
||||
/** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */
|
||||
private refreshLexicon(): void {
|
||||
const projection = this.project()
|
||||
const rolls = new Map<TriggerChar, readonly string[]>()
|
||||
for (const src of this.deps.roster.all()) {
|
||||
if (src.lexicon === undefined) continue
|
||||
const names = src.lexicon(projection)
|
||||
let names: readonly string[] | undefined
|
||||
try {
|
||||
names = src.lexicon(projection)
|
||||
} catch (error) {
|
||||
// A faulty source drops silently with a console record (the
|
||||
// candidate-fetch failure policy); the refresh runs inside
|
||||
// notification callbacks, where a throw would starve other consumers.
|
||||
console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error)
|
||||
continue
|
||||
}
|
||||
if (names === undefined) continue
|
||||
const prev = rolls.get(src.trigger)
|
||||
rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
|
||||
}
|
||||
return rolls
|
||||
this.lexicon.set(rolls)
|
||||
}
|
||||
|
||||
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
|
||||
private watchLexicon(source: SlashSource, projection: ClientSessionContext): void {
|
||||
if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
|
||||
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
|
||||
}
|
||||
|
||||
/** Launch the candidate fetch for one hit generation, superseding the previous one. */
|
||||
|
||||
@@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one trigger source.
|
||||
* Register one trigger source. Live session controllers are notified so a
|
||||
* source arriving after scope birth still warms and joins the lexicon.
|
||||
* @param src - the source; (trigger, name) must be unique — duplicates throw.
|
||||
* @returns the disposer (callers wrap registration in ctx.effect). Disposal
|
||||
* while a controller shows the source's menu group drops that group.
|
||||
@@ -49,6 +50,16 @@ export class SlashService extends Service implements SlashServiceContract {
|
||||
throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
|
||||
}
|
||||
live.sources.push(src)
|
||||
for (const controller of live.controllers.values()) {
|
||||
try {
|
||||
controller.sourceAdded(src)
|
||||
} catch (error) {
|
||||
// Contain faulty source callbacks (warm/subscribeLexicon): the
|
||||
// registration must stand with a usable disposer and the remaining
|
||||
// controllers must still be notified.
|
||||
console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
const at = live.sources.indexOf(src)
|
||||
if (at < 0) return
|
||||
|
||||
@@ -165,6 +165,16 @@ export interface SlashSource {
|
||||
* (the render path must stay synchronous and side-effect free).
|
||||
*/
|
||||
lexicon?(session: ClientSessionContext): readonly string[] | undefined
|
||||
/**
|
||||
* Subscribe to changes of this source's {@link SlashSource.lexicon} answer
|
||||
* for one session (backing data settled, invalidated, or refreshed). The
|
||||
* controller re-polls lexicon on each notification; a source whose roll
|
||||
* never changes after warm omits the hook.
|
||||
* @param session - stable session projection.
|
||||
* @param listener - invalidation callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void
|
||||
/** Reference codec; required for sources producing insert outcomes. */
|
||||
readonly codec?: ReferenceCodec
|
||||
}
|
||||
|
||||
@@ -126,6 +126,18 @@ describe('registerSource', () => {
|
||||
slash.registerSource(deferredSource('/', 'beta').source)
|
||||
})
|
||||
|
||||
it('a source registered after controller birth warms in every live controller', async () => {
|
||||
const { slash, mint } = await serviceBench()
|
||||
const ca = slash.sessionOf(mint('a').actx)
|
||||
const cb = slash.sessionOf(mint('b').actx)
|
||||
const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
|
||||
slash.registerSource(late.source)
|
||||
expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
|
||||
expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
|
||||
expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('HMR shape: dispose of the registering fiber removes the source', async () => {
|
||||
const { root, slash, mint } = await serviceBench()
|
||||
const controller = slash.sessionOf(mint('a').actx)
|
||||
@@ -513,7 +525,7 @@ describe('lexicon', () => {
|
||||
skill,
|
||||
lexSource('@', 'subagent', ['worker-1']),
|
||||
])
|
||||
const rolls = controller.lexicon()
|
||||
const rolls = controller.lexicon.getSnapshot()
|
||||
expect([...rolls.keys()]).toEqual(['/', '@'])
|
||||
expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
|
||||
expect(rolls.get('@')).toEqual(['worker-1'])
|
||||
@@ -522,7 +534,7 @@ describe('lexicon', () => {
|
||||
|
||||
it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
|
||||
const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
|
||||
expect(controller.lexicon().size).toBe(0)
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
})
|
||||
|
||||
it('two sources on one trigger concatenate in registration order', () => {
|
||||
@@ -531,10 +543,63 @@ describe('lexicon', () => {
|
||||
lexSource('/', 'prompt', ['c']),
|
||||
lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
|
||||
])
|
||||
const rolls = controller.lexicon()
|
||||
const rolls = controller.lexicon.getSnapshot()
|
||||
expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
|
||||
expect(rolls.has('@')).toBe(false)
|
||||
})
|
||||
|
||||
it('a source lexicon notification republishes the aggregated store', () => {
|
||||
let roll: readonly string[] | undefined = undefined
|
||||
let notify: (() => void) | undefined
|
||||
const source: SlashSource = {
|
||||
trigger: '/',
|
||||
name: 'skill',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
lexicon: () => roll,
|
||||
subscribeLexicon: (_session, listener) => {
|
||||
notify = listener
|
||||
return () => { notify = undefined }
|
||||
},
|
||||
}
|
||||
const { controller } = controllerBench([source])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
const seen: number[] = []
|
||||
controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
|
||||
roll = ['commit-helper']
|
||||
notify?.()
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
|
||||
expect(seen).toEqual([1])
|
||||
controller.dispose()
|
||||
expect(notify).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
|
||||
const { controller, sources } = controllerBench([])
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
const warm = vi.fn()
|
||||
const late: SlashSource = {
|
||||
trigger: '/',
|
||||
name: 'late',
|
||||
candidates: () => Promise.resolve([]),
|
||||
onPick: () => undefined,
|
||||
warm,
|
||||
lexicon: () => ['fresh'],
|
||||
}
|
||||
sources.push(late)
|
||||
controller.sourceAdded(late)
|
||||
expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('a removed source leaves the aggregated lexicon', () => {
|
||||
const src = lexSource('/', 'skill', ['gone'])
|
||||
const { controller, sources } = controllerBench([src])
|
||||
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
|
||||
sources.splice(sources.indexOf(src), 1)
|
||||
controller.sourceRemoved(src)
|
||||
expect(controller.lexicon.getSnapshot().size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('arbitrate', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* consumer merges keys in and the intersection is what keeps them string-typed.
|
||||
* The rule fires on the empty-map view, not on real redundancy. */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HostObservable } from './renderer.ts'
|
||||
import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
|
||||
|
||||
export * from './store.ts'
|
||||
@@ -214,11 +215,40 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
|
||||
*/
|
||||
export type SlotComponent<P> = (props: P) => ReactNode
|
||||
|
||||
/**
|
||||
* Registrant hooks compartment: bare observable sources (getSnapshot +
|
||||
* subscribe pairs) supplied under the reserved `hooks` key of an inject
|
||||
* face. The registrant-private twin of the `sessions.provide` hooks
|
||||
* compartment: the renderer binds each source into a `use<Name>` selector
|
||||
* hook, so the sources never reach the component and plugin-private reactive
|
||||
* facts ride the same subscription machinery as the standard kit instead of
|
||||
* hand-rolled component subscriptions.
|
||||
*/
|
||||
export type HooksSources = Record<string, HostObservable<unknown>>
|
||||
|
||||
/**
|
||||
* Selector-hook share synthesized from a hooks compartment: each source
|
||||
* `name` becomes a `use<Name>` selector hook over its snapshot type.
|
||||
*/
|
||||
export type PropsHooks<HS extends HooksSources> = {
|
||||
[N in keyof HS & string as `use${Capitalize<N>}`]:
|
||||
SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
|
||||
}
|
||||
|
||||
/**
|
||||
* The component-side view of an inject face: the reserved `hooks`
|
||||
* compartment (when declared) arrives as bound `use<Name>` selector hooks;
|
||||
* every other member passes through verbatim.
|
||||
*/
|
||||
export type InjectFace<I extends object> =
|
||||
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
|
||||
|
||||
/**
|
||||
* The four-share component props intersection: runtime share (SlotMap) +
|
||||
* child-render share (children declaration) + store share (declared handle) +
|
||||
* the registrant's injected business face. Each share derives from its single
|
||||
* source of truth; components reference this composition, never re-type it.
|
||||
* the registrant's injected business face (its hooks compartment bound, see
|
||||
* {@link InjectFace}). Each share derives from its single source of truth;
|
||||
* components reference this composition, never re-type it.
|
||||
*/
|
||||
export type ComposedProps<
|
||||
K extends keyof SlotMap & string,
|
||||
@@ -226,7 +256,7 @@ export type ComposedProps<
|
||||
H,
|
||||
I extends object,
|
||||
M = never,
|
||||
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I & MatchedShare<SlotMap[K], M>
|
||||
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
|
||||
|
||||
/**
|
||||
* Inject factory parameter list, derived from the registration's declaration:
|
||||
|
||||
@@ -105,18 +105,14 @@ export interface SlotRendererHost {
|
||||
sessions: {
|
||||
/** Session list source backing the useSessions standard hook. */
|
||||
list: HostObservable<unknown>
|
||||
/** Current-session source used by SessionProvider. */
|
||||
current: HostObservable<string | undefined>
|
||||
/** Resolve a definite session bundle, or undefined when the id is unknown. */
|
||||
provideInfo(id: string): SessionProvideInfo | undefined
|
||||
/**
|
||||
* Resolve the current-session-optional standard props bundle. The result
|
||||
* always carries the static provider roster, even when `id` is absent or
|
||||
* cannot resolve to a live session.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns the optional provide info.
|
||||
* Atomic current-session provide projection used by SessionProvider:
|
||||
* selection changes and provider-roster changes publish through this one
|
||||
* source, so a stable current id cannot strand mounted entries on an
|
||||
* obsolete hook/prop schema. Carries the static roster with sessionId
|
||||
* undefined while no current session resolves.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo
|
||||
provideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
}
|
||||
/** Workspace-side standard-kit sources. */
|
||||
workspaces: {
|
||||
|
||||
@@ -39,6 +39,10 @@ export function apply(ctx: ClientContext): void {
|
||||
// The list snapshot is always warm — the full running-children roster.
|
||||
return childLabels(session, '')
|
||||
},
|
||||
subscribeLexicon(_session, listener) {
|
||||
// The roll derives from the list snapshot, so its change feed IS the list's.
|
||||
return sessions.list.subscribe(listener)
|
||||
},
|
||||
onPick({ candidate }) {
|
||||
// Decision 21: plain-text reference — the literal lands in the draft
|
||||
// and ships to the model verbatim (trailing space closes the token).
|
||||
|
||||
@@ -32,17 +32,31 @@ function sessionsWith(sessions: SessionSummary[]) {
|
||||
const byId: Record<string, SessionSummary> = {}
|
||||
for (const s of sessions) byId[s.id] = s
|
||||
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
|
||||
return { list: { getSnapshot: () => snapshot } }
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
list: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
|
||||
},
|
||||
notify: () => { for (const fn of [...subs]) fn() },
|
||||
listenerCount: () => subs.size,
|
||||
}
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source. */
|
||||
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
|
||||
/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
|
||||
async function fullBench(sessions: SessionSummary[]) {
|
||||
const ctx = new Context()
|
||||
let captured: SlashSource | undefined
|
||||
const face = sessionsWith(sessions)
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('sessions', sessionsWith(sessions))
|
||||
ctx.provide('sessions', face)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return captured!
|
||||
return { source: captured!, face }
|
||||
}
|
||||
|
||||
/** Source-only bench for the behavior-contract suites. */
|
||||
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
|
||||
return (await fullBench(sessions)).source
|
||||
}
|
||||
|
||||
const FAMILY: SessionSummary[] = [
|
||||
@@ -113,6 +127,19 @@ describe('lexicon', () => {
|
||||
expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout'])
|
||||
expect(source.lexicon!(proj('childless'))).toEqual([])
|
||||
})
|
||||
|
||||
it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => {
|
||||
const { source, face } = await fullBench(FAMILY)
|
||||
let notified = 0
|
||||
const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 })
|
||||
expect(face.listenerCount()).toBe(1)
|
||||
face.notify()
|
||||
expect(notified).toBe(1)
|
||||
off()
|
||||
expect(face.listenerCount()).toBe(0)
|
||||
face.notify()
|
||||
expect(notified).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pick and codec', () => {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import {
|
||||
SlotOwnershipError, StaleAuthorizationError,
|
||||
type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
|
||||
type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
|
||||
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
|
||||
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
|
||||
@@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined
|
||||
const args: unknown[] = []
|
||||
if (info !== undefined) args.push(info.sessionId)
|
||||
if (actions !== undefined) args.push(actions)
|
||||
return (inject as (...args: unknown[]) => InjectedProps)(...args)
|
||||
return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind an inject face's reserved `hooks` compartment (bare observable
|
||||
* sources, see HooksSources) into `use<Name>` selector hooks — the
|
||||
* registrant-private twin of the provide-bundle binding in standardKit.
|
||||
* Runs once per cached inject result; hook identity rides observableHook's
|
||||
* per-source cache.
|
||||
*/
|
||||
function bindInjectHooks(face: InjectedProps): InjectedProps {
|
||||
const sources = face['hooks']
|
||||
if (sources === undefined) return face
|
||||
const { hooks: _hooks, ...rest } = face
|
||||
const bound: InjectedProps = rest
|
||||
for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
|
||||
const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
|
||||
bound[hookName] = observableHook(source)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {
|
||||
|
||||
@@ -90,9 +90,9 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
|
||||
*/
|
||||
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
|
||||
const host = useHost()
|
||||
const id = observableHook(host.sessions.current)(s => s)
|
||||
const info = observableHook(host.sessions.provideInfo)(s => s)
|
||||
return (
|
||||
<BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
|
||||
<BindingContext.Provider value={info}>
|
||||
{children}
|
||||
</BindingContext.Provider>
|
||||
)
|
||||
@@ -107,17 +107,17 @@ export interface SessionProviderProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-wired session area: subscribes to the host's current-session
|
||||
* source, resolves the session cell, and remounts the body under
|
||||
* `key={sessionId}` so a session switch rebuilds the session subtree. This
|
||||
* dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
|
||||
* branded type at the component boundary.
|
||||
* Framework-wired session area: subscribes to the host's current provide
|
||||
* source and remounts the body under `key={sessionId}` so a session switch
|
||||
* rebuilds the session subtree. This dependency-inverted layer uses plain
|
||||
* string ids; `PropsRuntime` applies the branded type at the component
|
||||
* boundary.
|
||||
*/
|
||||
export function SessionProvider({ empty, children }: SessionProviderProps) {
|
||||
const host = useHost()
|
||||
const id = observableHook(host.sessions.current)(s => s)
|
||||
const info = id === undefined ? undefined : host.sessions.provideInfo(id)
|
||||
if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
|
||||
const info = observableHook(host.sessions.provideInfo)(s => s)
|
||||
const id = info.sessionId
|
||||
if (id === undefined) return <>{empty?.() ?? null}</>
|
||||
return (
|
||||
<BindingContext.Provider value={info} key={id}>
|
||||
{children(id)}
|
||||
|
||||
@@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
|
||||
|
||||
/** Passthrough host over the real core (store/session seats unused here). */
|
||||
function hostOver(core: SlotCore): SlotRendererHost {
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
return {
|
||||
subscribe: (key, fn) => core.subscribe(key, fn),
|
||||
getVersion: key => core.getVersion(key),
|
||||
@@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost {
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
|
||||
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
},
|
||||
workspaces: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render } from '@testing-library/react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
|
||||
type RenderOpts, type SessionProvideInfo,
|
||||
@@ -85,7 +86,9 @@ function makeHost() {
|
||||
const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
|
||||
const list = observable<{ ids: string[] }>({ ids: [] })
|
||||
const workspaces = observable<{ ids: string[] }>({ ids: [] })
|
||||
const current = observable<string | undefined>(undefined)
|
||||
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
|
||||
let currentId: string | undefined
|
||||
const infos = new Map<string, SessionProvideInfo>()
|
||||
|
||||
const bump = (key: string) => {
|
||||
@@ -123,10 +126,7 @@ function makeHost() {
|
||||
},
|
||||
sessions: {
|
||||
list,
|
||||
current,
|
||||
provideInfo: id => infos.get(id),
|
||||
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: {}, props: {} },
|
||||
provideInfo: provide,
|
||||
},
|
||||
workspaces: { list: workspaces },
|
||||
}
|
||||
@@ -134,7 +134,14 @@ function makeHost() {
|
||||
host,
|
||||
list,
|
||||
workspaces,
|
||||
current,
|
||||
// Same driver surface as the old current cell: set(id) publishes the
|
||||
// resolved bundle (or the absent projection) through the provide source.
|
||||
current: {
|
||||
set: (id: string | undefined) => {
|
||||
currentId = id
|
||||
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
|
||||
},
|
||||
},
|
||||
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
|
||||
add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
|
||||
const entry = entryOf(partial)
|
||||
@@ -161,6 +168,7 @@ function makeHost() {
|
||||
props: {},
|
||||
}
|
||||
infos.set(id, info)
|
||||
if (currentId === id) provide.set(info)
|
||||
return info
|
||||
},
|
||||
}
|
||||
@@ -740,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
|
||||
expect(inject).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('binds the inject hooks compartment into use<Name> selector hooks (sources never reach the component)', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.single', SINGLE_ROOT)
|
||||
const badge = observable('cold')
|
||||
const seen: Record<string, unknown>[] = []
|
||||
h.add('k.single', {
|
||||
component: (props: { useBadge?: <S>(sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => {
|
||||
seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) })
|
||||
return null
|
||||
},
|
||||
inject: () => ({ plain: 'kept', hooks: { badge } }),
|
||||
})
|
||||
mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
|
||||
// The raw compartment is consumed by the binding; the plain member passes through.
|
||||
expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' })
|
||||
act(() => { badge.set('hot') })
|
||||
expect(seen.at(-1)!['read']).toBe('hot')
|
||||
})
|
||||
|
||||
it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
|
||||
const h = makeHost()
|
||||
h.declare('k.session', SINGLE_SESSION)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
createSlotRenderer, SessionProvider,
|
||||
type SessionProvideInfo, type SlotRendererHost,
|
||||
@@ -26,12 +26,14 @@ function observable<T>(initial: T) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal host: SessionProvider only reads sessions.current/cell, but it must
|
||||
* Minimal host: SessionProvider only reads sessions.provideInfo, but it must
|
||||
* render inside the renderer tree (HostContext), so the harness mounts a real
|
||||
* root entry whose body is the test's render-prop provider.
|
||||
*/
|
||||
function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
|
||||
const current = observable<string | undefined>(undefined)
|
||||
const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
|
||||
const provide = observable<SessionMaybeProvideInfo>(absentInfo)
|
||||
let currentId: string | undefined
|
||||
const infos = new Map<string, SessionProvideInfo>()
|
||||
const sessionEntries: StoredEntry[] = []
|
||||
const rootEntry: StoredEntry = {
|
||||
@@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: observable<unknown>({ ids: [] }),
|
||||
current,
|
||||
provideInfo: id => infos.get(id),
|
||||
maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
|
||||
?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
|
||||
provideInfo: provide,
|
||||
},
|
||||
workspaces: { list: observable<unknown>({ items: [] }) },
|
||||
}
|
||||
return {
|
||||
host,
|
||||
current,
|
||||
// Same driver surface as the old current cell: set(id) publishes the
|
||||
// resolved bundle (or the absent projection) through the provide source.
|
||||
current: {
|
||||
set: (id: string | undefined) => {
|
||||
currentId = id
|
||||
provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
|
||||
},
|
||||
},
|
||||
addSession: (id: string) => {
|
||||
// Bare source per bundle (identity-stable): the machinery binds useSession from it.
|
||||
const info: SessionProvideInfo = {
|
||||
@@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
|
||||
props: {},
|
||||
}
|
||||
infos.set(id, info)
|
||||
if (currentId === id) provide.set(info)
|
||||
return info
|
||||
},
|
||||
/** Swap one session's bundle in place (roster-change stand-in); republish when current. */
|
||||
replaceSession: (info: SessionProvideInfo) => {
|
||||
infos.set(info.sessionId, info)
|
||||
if (currentId === info.sessionId) provide.set(info)
|
||||
},
|
||||
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
|
||||
}
|
||||
}
|
||||
@@ -149,6 +161,28 @@ describe('SessionProvider', () => {
|
||||
expect(seen.at(-1)!['sessionId']).toBe('s2')
|
||||
})
|
||||
|
||||
it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
|
||||
const seen: unknown[] = []
|
||||
const h = makeHost({
|
||||
root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
|
||||
})
|
||||
const original = h.addSession('s1')
|
||||
h.registerSession({
|
||||
component: (props: { feature?: string }) => {
|
||||
seen.push(props.feature)
|
||||
return null
|
||||
},
|
||||
options: {},
|
||||
})
|
||||
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
|
||||
act(() => { h.current.set('s1') })
|
||||
expect(seen.at(-1)).toBeUndefined()
|
||||
// A provider-roster change rematerializes the bundle; the provide source
|
||||
// must carry it to already-mounted entries without a selection change.
|
||||
act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
|
||||
expect(seen.at(-1)).toBe('now-live')
|
||||
})
|
||||
|
||||
it('fails loud when mounted outside the renderer tree (no host channel)', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeHost() {
|
||||
const versions = new Map<string, number>()
|
||||
const subs = new Map<string, Set<() => void>>()
|
||||
const live = new Set<StoredEntry>()
|
||||
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
|
||||
const bump = (key: string) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
for (const fn of [...(subs.get(key) ?? [])]) fn()
|
||||
@@ -39,9 +40,7 @@ function makeHost() {
|
||||
storeOf: () => undefined,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
current: { getSnapshot: () => undefined, subscribe: () => () => {} },
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
|
||||
provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
|
||||
},
|
||||
workspaces: {
|
||||
list: { getSnapshot: () => ({}), subscribe: () => () => {} },
|
||||
|
||||
@@ -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/context/session-reference/README.md
|
||||
README.md: 2ca461f88b266b4dec1ffa4132c8cb17455f4b8e
|
||||
README.zh.md: 9f8fd0bace9b37b2f7885eded7686ecac8c625ba
|
||||
README.md: 6def2923cf3bc0021b0db578279a1b0571106d41
|
||||
README.zh.md: 9d7abfa78e6d35b2149c9436d5b397e4a30404d7
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Public API
|
||||
|
||||
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched.
|
||||
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched.
|
||||
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `UserMessageData` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`.
|
||||
- `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text.
|
||||
|
||||
@@ -21,7 +21,7 @@ The context source is `{ kind: 'session-reference', version: 1, references }`; e
|
||||
| Key | Default | Contract |
|
||||
|---|---:|---|
|
||||
| `maxReferences` | `3` | Maximum distinct source sessions in one prepared message; must be at most `3`. |
|
||||
| `candidateLimit` | `50` | Default metadata candidate count returned to a host. |
|
||||
| `candidateLimit` | `50` | Default candidate count returned to a host. |
|
||||
| `maxReferenceBytes` | `65536` | Maximum serialized JSON bytes for one reference object. |
|
||||
|
||||
Retention applies `maxReferenceBytes` independently to each source, keeps compact checkpoints and the newest message before dropping older non-checkpoint units, and uses `dsh-retention` head/tail truncation with an exact UTF-8 omission notice. If one source's fixed serialized fields cannot fit, preparation fails with `SESSION_REFERENCE_BUDGET_EXCEEDED` instead of returning a partial context.
|
||||
@@ -44,7 +44,7 @@ The snapshot and request are consecutive append-only target messages and preserv
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No title or full-text discovery** — candidates filter by session id and cwd only, although selected rows display the latest title. SQLite FTS may replace discovery later without changing URI, snapshot, or persistence contracts.
|
||||
- **No body discovery** — candidate queries inspect folded titles but do not search message bodies. A non-empty query may inspect every visible persisted session log through the session-query service's bounded, cancellable batch; a dedicated title index may replace that discovery path without changing URI, snapshot, or persistence contracts.
|
||||
- **Trusted caller boundary** — the service assumes its host is authorized to read every session exposed by `ctx.sessionQuery`; it is not a model-facing search tool.
|
||||
- **Text projection only** — non-text user and assistant blocks are not propagated across sessions.
|
||||
- **No live link** — references are snapshots, not forks, resumes, subscriptions, or source-session mutations.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 公开 API
|
||||
|
||||
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id 或 cwd 进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用最新的日志支持标题作为 mention label,并回退到会话 id;不搜索标题与消息主体。
|
||||
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或日志中最新的标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。
|
||||
- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `UserMessageData` 上下文。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()` 或 `steer()` 之前被拒绝。
|
||||
- `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:<base64url(JSON.stringify(sessionId))>`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。显式 Markdown mention 会拒绝每个格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| Key | 默认值 | 契约 |
|
||||
|---|---:|---|
|
||||
| `maxReferences` | `3` | 一条已准备消息中不同源会话的最大数量;必须不大于 `3`。 |
|
||||
| `candidateLimit` | `50` | 返回给宿主的默认元数据候选数量。 |
|
||||
| `candidateLimit` | `50` | 返回给宿主的默认候选数量。 |
|
||||
| `maxReferenceBytes` | `65536` | 一个引用对象的最大序列化 JSON 字节数。 |
|
||||
|
||||
保留会对每个源独立应用 `maxReferenceBytes`,保留 compact 检查点与最新消息,再丢弃较旧的非检查点单元,并使用 `dsh-retention` 头部/尾部截断和精确 UTF-8 省略通知。如果某个源的固定序列化字段无法容纳,准备会以 `SESSION_REFERENCE_BUDGET_EXCEEDED` 失败,而不返回部分上下文。
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有标题或全文发现**:候选会话只按会话 id 与 cwd 筛选,但已选行会显示最新标题。SQLite FTS 未来可以替换发现机制,而不改变 URI、快照或持久化契约。
|
||||
- **不支持正文发现**:候选查询会检查折叠后的标题,但不搜索消息主体。非空查询可能通过 session-query 服务有界、可取消的批处理检查每个可见的持久化会话日志;专用标题索引未来可以替换这条发现路径,而不改变 URI、快照或持久化契约。
|
||||
- **受信任调用方边界**:该服务假设宿主有权读取 `ctx.sessionQuery` 公开的每个会话;它不是面向模型的搜索工具。
|
||||
- **只投影文本**:不会在会话间传播非文本 user 与 assistant 块。
|
||||
- **没有实时链接**:引用是快照,不是 fork、恢复、订阅或源会话变更。
|
||||
|
||||
@@ -10,7 +10,7 @@ import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query'
|
||||
import {
|
||||
DEFAULT_CANDIDATE_LIMIT,
|
||||
DEFAULT_MAX_REFERENCE_BYTES,
|
||||
@@ -102,7 +102,7 @@ export class SessionReferenceService extends Service {
|
||||
/**
|
||||
* List reference candidates, ranked by working-directory affinity.
|
||||
* @param agent - target agent; self is excluded and its cwd drives ranking.
|
||||
* @param query - optional case-insensitive session-id/cwd substring.
|
||||
* @param query - optional case-insensitive session-id/cwd/title substring.
|
||||
* @param limit - optional positive result cap.
|
||||
* @param signal - optional cancellation boundary for host autocomplete teardown.
|
||||
* @returns candidates labeled by latest title or, when absent, session id.
|
||||
@@ -119,27 +119,42 @@ export class SessionReferenceService extends Service {
|
||||
const needle = query.toLocaleLowerCase()
|
||||
const targetCwd = agent.session.header.cwd
|
||||
assertNotCancelled(signal)
|
||||
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(), signal))
|
||||
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal))
|
||||
.filter(record => record.header.id !== agent.id)
|
||||
.filter((record) => {
|
||||
if (needle === '') return true
|
||||
return record.header.id.toLocaleLowerCase().includes(needle)
|
||||
|| record.header.cwd?.toLocaleLowerCase().includes(needle) === true
|
||||
})
|
||||
.map((record, index) => ({ record, index }))
|
||||
.sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|
||||
|| a.index - b.index)
|
||||
.slice(0, limit)
|
||||
const titles = await settleWithCancellation(
|
||||
Promise.all(records.map(({ record }) => this.ctx.sessionQuery.readTitle(record.header.id))),
|
||||
const inspected = needle === ''
|
||||
? records
|
||||
.sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|
||||
|| a.index - b.index)
|
||||
.slice(0, limit)
|
||||
: records
|
||||
const observations = await settleWithCancellation(
|
||||
this.ctx.sessionQuery.readTitleSnapshots(inspected.map(({ record }) => record.header.id), signal),
|
||||
signal,
|
||||
)
|
||||
return records.map(({ record }, index) => ({
|
||||
sessionId: record.header.id,
|
||||
label: titles[index]?.title ?? record.header.id,
|
||||
...record.header.cwd === undefined ? {} : { cwd: record.header.cwd },
|
||||
createdAt: record.header.createdAt,
|
||||
}))
|
||||
return inspected.map(({ record, index }, observationIndex) => {
|
||||
const observation = observations[observationIndex] as SessionTitleObservationResult
|
||||
return {
|
||||
record,
|
||||
index,
|
||||
label: observation.status === 'fulfilled'
|
||||
? observation.value.title?.title ?? record.header.id
|
||||
: record.header.id,
|
||||
}
|
||||
}).filter(({ record, label }) => {
|
||||
if (needle === '') return true
|
||||
return record.header.id.toLocaleLowerCase().includes(needle)
|
||||
|| record.header.cwd?.toLocaleLowerCase().includes(needle) === true
|
||||
|| label.toLocaleLowerCase().includes(needle)
|
||||
}).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|
||||
|| a.index - b.index)
|
||||
.slice(0, limit)
|
||||
.map(({ record, label }) => ({
|
||||
sessionId: record.header.id,
|
||||
label,
|
||||
...record.header.cwd === undefined ? {} : { cwd: record.header.cwd },
|
||||
createdAt: record.header.createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('session reference URI and inline mentions', () => {
|
||||
})
|
||||
|
||||
describe('session reference discovery and preparation', () => {
|
||||
it('ranks metadata candidates by cwd without depending on full-text search', async () => {
|
||||
it('matches candidate metadata and titles before ranking by cwd', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same', createdAt: 10 } })
|
||||
ctx.sessions.create(SessionId('other'), { meta: { cwd: '/else', createdAt: 40 } })
|
||||
@@ -210,6 +210,9 @@ describe('session reference discovery and preparation', () => {
|
||||
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([
|
||||
{ sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 },
|
||||
])
|
||||
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([
|
||||
{ sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 },
|
||||
])
|
||||
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), '', 0))
|
||||
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
|
||||
|
||||
@@ -229,6 +232,40 @@ describe('session reference discovery and preparation', () => {
|
||||
listSessions.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'))
|
||||
const source = ctx.sessions.create(SessionId('source'))
|
||||
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
|
||||
readTitles.mockResolvedValueOnce([{
|
||||
sessionId: source.id,
|
||||
status: 'rejected',
|
||||
reason: new Error('broken title log'),
|
||||
}])
|
||||
|
||||
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([
|
||||
{ sessionId: source.id, label: source.id, createdAt: source.header.createdAt },
|
||||
])
|
||||
|
||||
let releaseTitles: (() => void) | undefined
|
||||
let titleSignal: AbortSignal | undefined
|
||||
readTitles.mockImplementationOnce(async (_ids, signal) => {
|
||||
titleSignal = signal
|
||||
await new Promise<void>((resolve) => { releaseTitles = resolve })
|
||||
return []
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.sessionReferences.listCandidates(fakeAgent(target), 'source', undefined, controller.signal)
|
||||
await vi.waitFor(() => { expect(releaseTitles).toBeTypeOf('function') })
|
||||
expect(titleSignal).toBe(controller.signal)
|
||||
const cancelledTitles = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
|
||||
controller.abort('autocomplete superseded')
|
||||
await cancelledTitles
|
||||
releaseTitles?.()
|
||||
await Promise.resolve()
|
||||
readTitles.mockRestore()
|
||||
})
|
||||
|
||||
it('projects only the current user/assistant surface and records snapshot metadata', async () => {
|
||||
const ctx = await harness()
|
||||
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/target' } })
|
||||
|
||||
@@ -602,7 +602,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'async listCandidates( agent: Agent, query = \'\', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
|
||||
jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */',
|
||||
jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd/title substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise<PreparedReferencedMessage>',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sdk/sdk-client/README.md
|
||||
README.md: e2aaf08212307bfac0c73b5e838679a7a750a92a
|
||||
README.zh.md: cbefae59d95cc0cb9d89145ad3f2ee3248822714
|
||||
README.md: 33a933e10abfa865cf9ce34b87c377d07081cc68
|
||||
README.zh.md: 9f4453a00efef2685acec0194f83fcec2edf1409
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
|
||||
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
|
||||
|
||||
Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern.
|
||||
|
||||
@@ -20,11 +20,11 @@ const result = await harness.run('say hi')
|
||||
console.log(result.status, result.finalResponse)
|
||||
```
|
||||
|
||||
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), plus every `session.event` envelope and raw notification observed for that session tree, in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
|
||||
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
|
||||
|
||||
## HarnessClient
|
||||
|
||||
The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
|
||||
The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
|
||||
|
||||
`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API,`HarnessClient` 是低层协议客户端。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
|
||||
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
|
||||
|
||||
与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费者——[`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
|
||||
|
||||
@@ -20,11 +20,11 @@ const result = await harness.run('say hi')
|
||||
console.log(result.status, result.finalResponse)
|
||||
```
|
||||
|
||||
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本),以及该会话树内按线序观察到的全部 `session.event` 封套与原始通知。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。
|
||||
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按线序排列。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。
|
||||
|
||||
## HarnessClient
|
||||
|
||||
回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型:`JsonRpcResponseError`(线上错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
|
||||
回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型且由本包导出:`JsonRpcResponseError`(线上错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
|
||||
|
||||
`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该接缝记载的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user