From 899d25dfb3c94979c2f54e5167db37390eab4e3f Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 10:22:58 +0800 Subject: [PATCH 1/2] fix(tui): defer model-context resolution on the adapter-registration race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loader activation is service-driven, so the TUI can mount before a configured adapter plugin registers its provider routes; every fresh session then printed 'Could not resolve model context: no adapter registered for provider …' for a working configuration. The model controller now treats a NO_ADAPTER rejection of the context-window resolution as transient: it parks the resolution silently and re-resolves on the next llm/adapters-updated commit. A commit that still lacks the route parks the wait again; any target change clears it; all other resolution errors still surface. A wrong provider name keeps failing loudly at dispatch, where it is actionable. --- ...30-tui-adapter-registration-race.i18n.yaml | 6 ++ ...026-07-30-tui-adapter-registration-race.md | 29 ++++++++ ...-07-30-tui-adapter-registration-race.zh.md | 29 ++++++++ docs/event-producer-consumer.md | 2 +- packages/ui/tui/src/chat/model-command.ts | 21 +++++- packages/ui/tui/tests/tui.spec.ts | 66 +++++++++++++++++++ 6 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml new file mode 100644 index 0000000000..93fd2279d1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md +2026-07-30-tui-adapter-registration-race.md: e77a338d6b23a48dd140bd9160f22746c43f9fae +2026-07-30-tui-adapter-registration-race.zh.md: ce60b88ca7c62e09d7ad1a166932d86e4b240e03 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md new file mode 100644 index 0000000000..e77a338d6b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md @@ -0,0 +1,29 @@ +# Agent Note: TUI model-context resolution defers on the adapter-registration race + +Status: implemented + +English | [中文](2026-07-30-tui-adapter-registration-race.zh.md) + +## Problem + +Cordis activates plugins by service availability, not configuration order, so the TUI (whose `inject` requires only the `llm` service) can mount before a configured adapter plugin such as `dsh-llm-pi-ai` finishes registering its provider routes. The TUI's model controller resolves the selected model's context window immediately on mount; when the agent's route pointed at a not-yet-registered provider, `resolveModelInfo` rejected with `NO_ADAPTER` and every fresh session printed `Could not resolve model context: no adapter registered for provider "…"` — a spurious error for a fully working configuration (the adapter registered milliseconds later, and chatting worked). + +## Decision + +The TUI model controller treats a `NO_ADAPTER` rejection of its context-window resolution as a transient state rather than an error: it parks the resolution silently and re-resolves on the next `llm/adapters-updated` commit — the payload-free registry notification `LlmService` already fires at every route commit point. A commit that still lacks the route parks the wait again, so unrelated topology changes stay silent. Any target change re-enters the resolution and clears the pending wait, so the deferred state can never go stale against the current selection; every other resolution error still prints the notice. + +## Alternatives considered + +**Have the TUI wait for boot to settle before resolving.** The TUI has no Loader dependency (tests and embedders run without one) and "settled" is not observable from inside a plugin; adding a Loader coupling for one cosmetic resolution inverts the dependency direction. + +**Poll or retry with a timer.** A timer guesses at activation latency, still mis-prints on a slow adapter, and adds a tunable with no owner. The registry already announces every commit through `llm/adapters-updated`; subscribing is precise and free. + +**Order the config so adapters load first.** Row order carries no load semantics in the Loader (activation is service-driven by design), so this cannot be expressed in configuration. + +**Suppress NO_ADAPTER errors entirely.** A permanently missing adapter (typo in the provider name) would then never surface in the context-window path. Deferring keeps the signal: a wrong provider name still shows `model unset`-like behavior in the selector and fails loudly at dispatch, while the startup race resolves itself. + +**Resolve the context window per submitted message instead of at mount.** The send path already resolves per step (`prepareCall()`), and the indicator is displayed continuously, not only when sending; per-submit display resolution would leave the indicator blank until the first message and re-run adapter I/O for a value that only changes on route changes. + +## Consequences + +A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked. Covered by two TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, and a target change drops the stale wait. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md new file mode 100644 index 0000000000..ce60b88ca7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 模型上下文解析在适配器注册竞争时延后重试 + +Status: implemented + +[English](2026-07-30-tui-adapter-registration-race.md) | 中文 + +## Problem + +Cordis 按服务可用性而非配置顺序激活插件,因此 TUI(其 `inject` 只要求 `llm` 服务)可能在 `dsh-llm-pi-ai` 这类已配置的适配器插件完成提供方路由注册之前就挂载。TUI 的模型控制器在挂载时立即解析所选模型的上下文窗口;当 agent 的路由指向尚未注册的提供方时,`resolveModelInfo` 以 `NO_ADAPTER` 拒绝,于是每个新会话都会打印 `Could not resolve model context: no adapter registered for provider "…"` —— 对一份完全正常的配置报出的虚假错误(适配器几毫秒后就完成注册,对话也一切正常)。 + +## Decision + +TUI 模型控制器把上下文窗口解析中的 `NO_ADAPTER` 拒绝视为瞬态状态而非错误:静默搁置这次解析,并在下一次 `llm/adapters-updated` 提交时重新解析——这是 `LlmService` 本就在每个路由提交点发出的无载荷注册表通知。若某次提交仍缺少该路由,等待会被再次搁置,因此无关的拓扑变化保持沉默。任何目标变更都会重新进入解析并清除挂起的等待,因此延后状态绝不会相对当前选择变陈旧;其他所有解析错误仍照常打印通知。 + +## Alternatives considered + +**让 TUI 等启动结算后再解析。** TUI 不依赖 Loader(测试和嵌入方在没有 Loader 的环境下运行),而且"已结算"在插件内部不可观测;为一次外观性的解析引入 Loader 耦合会颠倒依赖方向。 + +**用定时器轮询或重试。** 定时器只能猜测激活延迟,遇到慢适配器仍会误报,还会引入一个没有归属者的可调参数。注册表本就通过 `llm/adapters-updated` 公告每次提交;订阅它既精确又零成本。 + +**调整配置顺序让适配器先加载。** Loader 中行顺序不承载加载语义(激活按设计由服务驱动),因此这无法用配置表达。 + +**彻底压制 NO_ADAPTER 错误。** 那样的话,永久缺失的适配器(提供方名字拼错)在上下文窗口路径上就永远不会暴露。延后重试保留了信号:错误的提供方名字仍会在选择器中表现出类似 `model unset` 的行为,并在分派时大声失败,而启动竞争则自行化解。 + +**改为在每次提交消息时解析上下文窗口,而不是在挂载时。** 发送路径本就按步解析(`prepareCall()`),且指示器是持续显示的,不只在发送时;按提交解析显示值会让指示器在首条消息之前一直空白,并为一个仅在路由变化时才变的值反复执行适配器 I/O。 + +## Consequences + +真正配置错误的提供方不再在启动时打印上下文解析错误——它改在首次分派时暴露,那才是该失败可以被处理的地方。控制器订阅每次 `llm/adapters-updated` 提交,但只在有等待被搁置时才动作。由两个 TUI 测试覆盖:延后的解析在无关提交中保持沉默、在该路由的提交到来时完成;目标变更丢弃陈旧等待。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e3d2b8dca6..5d32ec6ca2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -32,7 +32,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm), [`tui`](../packages/ui/tui) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../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-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | diff --git a/packages/ui/tui/src/chat/model-command.ts b/packages/ui/tui/src/chat/model-command.ts index 133a3d0d9f..794f293496 100644 --- a/packages/ui/tui/src/chat/model-command.ts +++ b/packages/ui/tui/src/chat/model-command.ts @@ -8,7 +8,7 @@ */ import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' -import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { TuiOverlaySession } from '../extension/types.ts' import { displayText } from '../components/text.ts' import { @@ -55,8 +55,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle let modelOverlay: TuiOverlaySession | undefined let modelCommands = Promise.resolve() + // A route whose adapter has not registered yet. Loader activation order is + // service-driven, so the TUI can mount before a configured adapter plugin + // activates; that transient NO_ADAPTER is not an error — the resolution + // waits for the next `llm/adapters-updated` commit instead of surfacing it. + let awaitingAdapter = false + const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { contextWindow = undefined + awaitingAdapter = false const resolution: Promise = selected === undefined ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( @@ -67,6 +74,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle void resolution.then((result) => { if (contextResolution !== resolution) return if (result.kind === 'error') { + if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') { + awaitingAdapter = true + return + } deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') return } @@ -74,6 +85,14 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle deps.requestRender() }) } + // The wait cannot go stale against `target.current`: every target change + // re-enters resolveContextWindow, which clears it. A commit that still + // lacks the route parks the resolution again rather than erroring, so + // unrelated topology changes stay silent. + ctx.on('llm/adapters-updated', () => { + if (deps.isDisposed() || !awaitingAdapter) return + resolveContextWindow(target.current) + }) resolveContextWindow(target.current) const selectModel = ( diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 79aa6f4f97..9e93cd49b9 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -10,6 +10,7 @@ import AgentRegistry, { } from '@deepseek-ai/dsh-agent' import { createUserMessage, createToolResultMessage, + LlmError, ReasoningEffortId, type LlmCallConfig, type LlmModelReasoningInfo, @@ -3630,6 +3631,71 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(reasoningFailed) }) + it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => { + // Loader activation order is service-driven: the TUI can mount before a + // configured adapter plugin activates, so the initial resolveModelInfo + // fails with NO_ADAPTER. That transient state must not print an error; + // the resolution retries on llm/adapters-updated. + const adapters = new Set() + const result = await setup({ + agentOptions: { provider: 'openai-codex', model: 'gpt-x' }, + contextTokens: 50_000, + catalog: { + providers: [], + models: [], + resolveModelInfo: () => adapters.has('openai-codex') + ? Promise.resolve({ context: { contextWindow: 100_000 } }) + : Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')), + }, + }) + await tick() + expect(result.terminal.output).not.toContain('Could not resolve model context') + + // A topology commit that still lacks the route parks the wait again. + result.ctx.emit('llm/adapters-updated') + await tick() + expect(result.terminal.output).not.toContain('% context') + expect(result.terminal.output).not.toContain('Could not resolve model context') + + adapters.add('openai-codex') + result.ctx.emit('llm/adapters-updated') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('% context') + }) + expect(result.terminal.output).not.toContain('Could not resolve model context') + + // A commit after satisfaction is a no-op for the resolved value. + result.ctx.emit('llm/adapters-updated') + await tick() + expect(result.terminal.output).not.toContain('Could not resolve model context') + await dispose(result) + }) + + it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => { + const result = await setup({ + agentOptions: { provider: 'openai-codex', model: 'gpt-x' }, + catalog: { + providers: [{ id: 'alpha', name: 'Alpha' }], + models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], + resolveModelInfo: provider => provider === 'alpha' + ? Promise.resolve({ context: { contextWindow: 64_000 } }) + : Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')), + }, + }) + await tick() + // Switching the model re-resolves and clears the deferred wait, so the + // stale route's adapter arriving afterwards must be a no-op. + result.terminal.send('/model alpha/a1') + result.terminal.send('\r') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Model selected: alpha/a1') + }) + result.ctx.emit('llm/adapters-updated') + await tick() + expect(result.terminal.output).not.toContain('Could not resolve model context') + await dispose(result) + }) + it('does not render a model catalog that resolves after TUI disposal', async () => { const deferred = Promise.withResolvers() const result = await setup({ From 732c9e6d954151b38f2a2c74b7c3de3a9c18ecd1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 15:11:24 +0800 Subject: [PATCH 2/2] fix(tui): release the adapter-registration listener on channel detach Review follow-up: the llm/adapters-updated listener's disposer was discarded, leaving it firing (harmlessly, behind isDisposed()) between TUI shutdown and fiber disposal, asymmetric with the sibling channel listeners. The controller now exposes detach(), and the channel's detachListeners() calls it on both the dispose() and startup-failure paths. --- ...30-tui-adapter-registration-race.i18n.yaml | 4 +-- ...026-07-30-tui-adapter-registration-race.md | 2 +- ...-07-30-tui-adapter-registration-race.zh.md | 2 +- packages/ui/tui/src/chat/model-command.ts | 10 ++++++-- packages/ui/tui/src/index.ts | 1 + packages/ui/tui/tests/tui.spec.ts | 25 +++++++++++++++++++ 6 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml index 93fd2279d1..b3d987b985 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md -2026-07-30-tui-adapter-registration-race.md: e77a338d6b23a48dd140bd9160f22746c43f9fae -2026-07-30-tui-adapter-registration-race.zh.md: ce60b88ca7c62e09d7ad1a166932d86e4b240e03 +2026-07-30-tui-adapter-registration-race.md: fd08e7b6130bc8f7e3cd5287a9970f5fb47244a8 +2026-07-30-tui-adapter-registration-race.zh.md: 0c6bba4bbc8c3303d9c471c3164faa816438b333 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md index e77a338d6b..fd08e7b613 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md @@ -26,4 +26,4 @@ The TUI model controller treats a `NO_ADAPTER` rejection of its context-window r ## Consequences -A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked. Covered by two TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, and a target change drops the stale wait. +A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked; the listener's disposer is released by the channel's `detachListeners()` through the controller's `detach()`, symmetric with the sibling channel listeners. Covered by three TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, a target change drops the stale wait, and after channel detach a registry commit no longer re-enters resolution. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md index ce60b88ca7..0c6bba4bbc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md @@ -26,4 +26,4 @@ TUI 模型控制器把上下文窗口解析中的 `NO_ADAPTER` 拒绝视为瞬 ## Consequences -真正配置错误的提供方不再在启动时打印上下文解析错误——它改在首次分派时暴露,那才是该失败可以被处理的地方。控制器订阅每次 `llm/adapters-updated` 提交,但只在有等待被搁置时才动作。由两个 TUI 测试覆盖:延后的解析在无关提交中保持沉默、在该路由的提交到来时完成;目标变更丢弃陈旧等待。 +真正配置错误的提供方不再在启动时打印上下文解析错误——它改在首次分派时暴露,那才是该失败可以被处理的地方。控制器订阅每次 `llm/adapters-updated` 提交,但只在有等待被搁置时才动作;监听器的 disposer 经由控制器的 `detach()` 在频道的 `detachListeners()` 中释放,与同级频道监听器保持对称。由三个 TUI 测试覆盖:延后的解析在无关提交中保持沉默、在该路由的提交到来时完成;目标变更丢弃陈旧等待;频道 detach 之后注册表提交不再重新进入解析。 diff --git a/packages/ui/tui/src/chat/model-command.ts b/packages/ui/tui/src/chat/model-command.ts index 794f293496..c86b3b7e3d 100644 --- a/packages/ui/tui/src/chat/model-command.ts +++ b/packages/ui/tui/src/chat/model-command.ts @@ -37,6 +37,8 @@ export interface ModelController { resetContextResolution(): void /** Forget the tracked selector overlay (shutdown). */ clearOverlay(): void + /** Remove the adapter-registration listener (channel detach). */ + detach(): void } type ContextResolution = @@ -88,8 +90,9 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle // The wait cannot go stale against `target.current`: every target change // re-enters resolveContextWindow, which clears it. A commit that still // lacks the route parks the resolution again rather than erroring, so - // unrelated topology changes stay silent. - ctx.on('llm/adapters-updated', () => { + // unrelated topology changes stay silent. The disposer rides the channel's + // detachListeners() through detach(), matching the sibling listeners. + const disposeAdapterListener = ctx.on('llm/adapters-updated', () => { if (deps.isDisposed() || !awaitingAdapter) return resolveContextWindow(target.current) }) @@ -206,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle clearOverlay(): void { modelOverlay = undefined }, + detach(): void { + disposeAdapterListener() + }, } } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a1250bb5b3..e2eaf89986 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1563,6 +1563,7 @@ export function createTuiChat( disposeAgent() disposeSchemeListener() disposeTargetListeners() + modelController.detach() } // Sweep reveal of the whole banner: the header wipes in left-to-right over diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 9e93cd49b9..962df7bd2d 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3671,6 +3671,31 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('stops listening for adapter registrations after channel detach', async () => { + // The listener disposer rides detachListeners() through the controller's + // detach(): after dispose, a registry commit must not re-enter resolution + // at all (the isDisposed() guard is a fallback, not the removal). + const calls: string[] = [] + const result = await setup({ + agentOptions: { provider: 'openai-codex', model: 'gpt-x' }, + catalog: { + providers: [], + models: [], + resolveModelInfo: (provider) => { + calls.push(provider) + return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')) + }, + }, + }) + await tick() + const callsAtDetach = calls.length + await result.controller.dispose() + result.ctx.emit('llm/adapters-updated') + await tick() + expect(calls.length).toBe(callsAtDetach) + await result.ctx.fiber.dispose() + }) + it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => { const result = await setup({ agentOptions: { provider: 'openai-codex', model: 'gpt-x' },