fix(web): follow a blank session's preset switch in the slash catalog
Presets own the rows that decide what a session's `/` menu contains, but both browser catalogs cache per session and had no invalidation edge for a recompose: `commands/changed` is registry-wide and recomposing registers nothing, so the menu kept serving the composition the session no longer ran. The host stream now frames the logged `agent-preset/selected` commit as `host/session-preset-changed`; the runtime bridges it to the typed `session/preset-changed` event, `ui-command` soft-refreshes that session's directory key and `ui-skill` invalidates its catalog entry. Reaching the host on a second switch was a separate defect: the list-row identity guard compared every summary field except `agentPreset`, and the merge keeps the row's `updatedAt`, so a switched row looked unchanged and served its cached instance forever. The hero chip compares the pick against that row, so switching back to the creation-time preset sent no RPC at all.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
|
||||
README.md: 677ac215d299fca695a6b27c564779ef1d3fd6ee
|
||||
README.zh.md: 8f1f69b26a932aaa300bdda1d4ec7b2fa749fe3c
|
||||
README.md: f6bf71bab5c5335d3da073101bcbafb30d1c2757
|
||||
README.zh.md: eae61780df7ccee350956dc542ebda70c671feb3
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`.
|
||||
Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, `session/preset-changed` drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`.
|
||||
|
||||
A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
|
||||
skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`session/preset-changed` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
|
||||
|
||||
pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* Catalog fetches are cached per session (the small twin of the ui-command
|
||||
* directory): the per-keystroke candidates re-poll filters a settled
|
||||
* snapshot locally, so one session costs one RPC. The scope-birth warm hook
|
||||
* prewarms the session's key; connection/reset clears everything — the host
|
||||
* prewarms the session's key; a preset switch drops that one key (the
|
||||
* catalog is the preset's, and a blank session may switch after the warm);
|
||||
* connection/reset clears everything — the host
|
||||
* catalog may differ across generations. A shared in-flight fetch
|
||||
* deliberately outlives any single menu interaction: closing the menu must
|
||||
* not kill the prewarm other consumers will hit, so it carries its own
|
||||
@@ -174,6 +176,9 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
}
|
||||
const slash = ctx.get('slash') as SlashServiceContract
|
||||
// A preset decides which skill providers an agent reads, so a switched
|
||||
// session's cached catalog belongs to the composition it no longer runs.
|
||||
ctx.on('session/preset-changed', invalidate)
|
||||
ctx.on('connection/reset', clearAll)
|
||||
ctx.effect(() => {
|
||||
const unregister = slash.registerSource(source)
|
||||
|
||||
@@ -263,6 +263,21 @@ describe('catalog cache', () => {
|
||||
expect(payloads).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('session/preset-changed clears only the recomposed session', async () => {
|
||||
const { list, payloads } = countingList()
|
||||
const { ctx, source } = await bench(list)
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
await source.candidates(proj('s2'), req(''))
|
||||
expect(payloads).toHaveLength(2)
|
||||
// The catalog a preset supplies is the preset's; the other session's
|
||||
// composition did not change, so its cached catalog still holds.
|
||||
ctx.emit('session/preset-changed', sid('s1'), 'minimal')
|
||||
await source.candidates(proj('s1'), req(''))
|
||||
await source.candidates(proj('s2'), req(''))
|
||||
expect(payloads).toHaveLength(3)
|
||||
expect(payloads[2]).toEqual({ sessionId: 's1' })
|
||||
})
|
||||
|
||||
it('connection/reset clears every cached session', async () => {
|
||||
const { list, payloads } = countingList()
|
||||
const { ctx, source } = await bench(list)
|
||||
|
||||
Reference in New Issue
Block a user