From d833be412afa0f091d9f206140fc095847681198 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:33:37 +0800 Subject: [PATCH] fix(client): contain notification-callback failures and document the source lifecycle Review follow-ups: the three new notify loops (currentProvideInfo subscribers, ui-skill lexicon listeners, late-registration controller setup) now contain per-callback failures so one faulty consumer cannot starve the rest, abort the list projection pass, or poison the source roster with no disposer; controller lexicon polling drops a throwing source with a console record like the candidate path. The ui-slash README (both languages) now states the late-registration warm and the subscribeLexicon contract, and the scenario suite drives a typed /name token gaining its decoration when the roll settles with no further input. --- .../runtime/src/client/sessions/service.ts | 11 ++++++- .../tests/input-scenarios.spec.tsx | 29 +++++++++++++++++++ packages/client/ui-skill/src/client/index.ts | 11 ++++++- packages/client/ui-slash/README.i18n.yaml | 6 ++-- packages/client/ui-slash/README.md | 2 +- packages/client/ui-slash/README.zh.md | 2 +- .../client/ui-slash/src/client/controller.ts | 11 ++++++- .../client/ui-slash/src/client/service.ts | 11 ++++++- 8 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 759c320bde..9754c87345 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -275,7 +275,16 @@ export class SessionsService { const next = this.maybeProvideInfo(this.list.getSnapshot().current) if (next === this.currentProvideInfoSnapshot) return this.currentProvideInfoSnapshot = next - for (const fn of [...this.currentProvideInfoListeners]) fn() + for (const fn of [...this.currentProvideInfoListeners]) { + try { + fn() + } catch (error) { + // Contain subscriber failures: this notify runs inside the list + // notification, where a throwing render-side subscriber would starve + // later listeners and abort the projection pass that scheduled it. + console.error('sessions.currentProvideInfo subscriber failed:', error) + } + } } /** Build the static no-session kit and reject duplicate declared names. */ diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 826405f2be..807650169b 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -236,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => { }) }) +describe('scenario: reference decoration lights up when the lexicon settles', () => { + it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => { + let roll: readonly string[] | undefined + let notify: (() => void) | undefined + const b = await scopedBench((slash) => { + slash.registerSource({ + trigger: '/', name: 'skill', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + lexicon: () => roll, + subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => { + notify = listener + return () => { notify = undefined } + }, + } as never) + }) + // Typed before the catalog settled: a plain token, no decoration. + b.type('/deploy now') + expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull() + // The catalog settles (ui-skill's settle path fires the same notification). + act(() => { + roll = ['deploy'] + notify?.() + }) + const mark = b.view.container.querySelector('[data-decoration="text-ref"]') + expect(mark?.textContent).toBe('/deploy') + }) +}) + describe('scenario I: unknown /xyz + enter', () => { it('adjudication misses in one hop and the whole line rides the default sink', async () => { const b = await bench() diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 7226f45163..d34c6ba96d 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -48,7 +48,16 @@ export function apply(ctx: ClientContext): void { const lexiconListeners = new Map void>>() const notifyLexicon = (sessionId: SessionId): void => { - for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) listener() + for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) { + try { + listener() + } catch (error) { + // Contain listener failures: settlement notifies from an ignored + // promise chain (a throw would surface as an unhandled rejection) + // and one faulty consumer must not starve the others. + console.error('[ui-skill] lexicon listener failed:', error) + } + } } const fetchCatalog = (sessionId: SessionId): Promise => { diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index c09d7f4c28..1053e205b0 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a -README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018 +# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md +README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d +README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index d2978695d7..4e363c2682 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 6aeb078a92..76d39673cb 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index 9e95dbdd41..ab0b26da54 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -290,7 +290,16 @@ export class SlashController { const rolls = new Map() for (const src of this.deps.roster.all()) { if (src.lexicon === undefined) continue - const names = src.lexicon(projection) + let names: readonly string[] | undefined + try { + names = src.lexicon(projection) + } catch (error) { + // A faulty source drops silently with a console record (the + // candidate-fetch failure policy); the refresh runs inside + // notification callbacks, where a throw would starve other consumers. + console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error) + continue + } if (names === undefined) continue const prev = rolls.get(src.trigger) rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names]) diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index 0ca3b91c2a..c47d44c3d4 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -50,7 +50,16 @@ export class SlashService extends Service implements SlashServiceContract { throw new Error(`slash source "${src.trigger}${src.name}" is already registered`) } live.sources.push(src) - for (const controller of live.controllers.values()) controller.sourceAdded(src) + for (const controller of live.controllers.values()) { + try { + controller.sourceAdded(src) + } catch (error) { + // Contain faulty source callbacks (warm/subscribeLexicon): the + // registration must stand with a usable disposer and the remaining + // controllers must still be notified. + console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error) + } + } return () => { const at = live.sources.indexOf(src) if (at < 0) return