Răsfoiți Sursa

test(llm-pi-ai): cover recovery diagnostics and document repair limits

Yichen Jiang 1 lună în urmă
părinte
comite
8dc30d270c

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.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-09-07-pi-ai-settings-catalog-recovery.md
-2026-09-07-pi-ai-settings-catalog-recovery.md: 13c23233e924b6ba2e0b22381e791c5ccdcdc254
-2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 6f001f545108aa2006491127318ae3123722364f
+2026-09-07-pi-ai-settings-catalog-recovery.md: 7b9fba06f6d5447654420a5912e45d1684ec5041
+2026-09-07-pi-ai-settings-catalog-recovery.zh.md: d68a36cffd240df63aeb7cbb5d1922b383360fd8

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md

@@ -14,7 +14,7 @@ The pi-ai consumer uses the existing settings `validate` callback. During namesp
 
 Initial profile resolution retains catalog diagnostics, while schema and self-contained profile constraints still reject loading. Writes strictly resolve each new or changed provider, comparing effective provider values against the committed snapshot. Unchanged failed providers do not block another provider's edit, and deletion remains possible. Editing a provider-wide setting validates all models it affects.
 
-Profile resolution keeps valid models beside per-model errors. A missing override retains its diagnostic without disabling the remaining catalog. A route-level catalog failure retains its provider and editable settings but supplies no callable models. The adapter checks the selected model's recorded failure before credentials or network I/O and reports `INVALID_CONFIG`. No protocol is guessed and no user configuration is rewritten during loading. Immutable snapshots still keep an in-flight request on its captured configuration.
+Profile resolution keeps valid models beside per-model errors. A missing override retains its diagnostic without disabling the remaining catalog. A route-level catalog failure retains its provider and editable settings but supplies no callable models. When route-wide validation aborts catalog resolution, the incomplete catalog and its collected per-model diagnostics are discarded; model requests on that route report the route-level error. The adapter checks the selected model's recorded failure before credentials or network I/O and reports `INVALID_CONFIG`. No protocol is guessed and no user configuration is rewritten during loading. Immutable snapshots still keep an in-flight request on its captured configuration.
 
 `LlmConfigurableProvider.error` carries the first diagnostic for the provider row. The configurable-provider directory publishes diagnostic changes so configuration repair refreshes the browser without re-registering the adapter. Failed model ids remain in settings, while the model selector receives serviceable entries. Models settings displays the diagnostic and retains edit/delete controls. Both add actions require their owning settings namespace; the ordinary add menu filters out unavailable namespaces.
 
@@ -32,7 +32,7 @@ This extends the [provider-routed adapter decision](../architecture/2026-07-14-p
 
 ## Consequences
 
-Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. No settings API, storage format, or session event is added; configurable-provider entries gain one optional diagnostic field.
+Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. Models settings does not explain namespace registration failures; those errors require inspecting the configuration and startup diagnostics. No settings API, storage format, or session event is added; configurable-provider entries gain one optional diagnostic field.
 
 ## Testing
 

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md

@@ -2,7 +2,7 @@
 
 Status: implemented
 
-English | [中文](2026-09-07-pi-ai-settings-catalog-recovery.md)
+[English](2026-09-07-pi-ai-settings-catalog-recovery.md) | 中文
 
 ## Problem
 
@@ -14,7 +14,7 @@ pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期
 
 首次解析 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。
 
-Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。
+Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。路由级校验中止目录解析时,不完整的目录及其已收集的逐模型诊断会被丢弃,该路由上的模型请求统一报告路由级错误。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。
 
 `LlmConfigurableProvider.error` 为提供方行携带首个诊断。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。
 
@@ -32,7 +32,7 @@ Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆
 
 ## Consequences
 
-依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。
+依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。模型设置页不展示命名空间注册失败的原因,此类错误需要检查配置与启动诊断。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。
 
 ## Testing
 

+ 20 - 0
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -348,6 +348,26 @@ describe('ModelsSection', () => {
     expect(document.body.textContent).toBe('')
   })
 
+  it('shows a configuration diagnostic inside the first-run setup card', async () => {
+    const scripted = scriptedFace()
+    const failure = 'The provider configuration needs repair'
+    scripted.face.llm.listProviders.mockResolvedValue(remoteOk([
+      { id: 'deepseek-official', name: 'DeepSeek' },
+    ]))
+    scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([
+      { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], error: failure },
+    ]))
+    scripted.face.credentials.describe.mockResolvedValue(remoteOk({
+      DEEPSEEK_API_KEY: { configured: false, writable: true },
+    }))
+    await mountFace(scripted)
+
+    const card = screen.getByRole('listitem')
+    expect(within(card).getByRole('alert').textContent).toBe(failure)
+    expect(within(card).getByLabelText(en.keyInput)).toBeTruthy()
+    expect(within(card).queryByRole('button', { name: deepSeekCopy(en.editProvider) })).toBeNull()
+  })
+
   it('dispatches the provider-card seat per rendered row, keyed by the owning namespace', async () => {
     const { renderSlot } = await mountSection()
     const cards = cardSeatCalls(renderSlot)

+ 2 - 2
packages/llm/llm-pi-ai/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
-README.md: 799c99386443922bd8d4e24d8f84a826d18ff67e
-README.zh.md: 8aecbc840725e02e565f2b228444d03565d931e4
+README.md: bef10768bfa758a29781b6eae0bc4c52ada8eba0
+README.zh.md: 870cf78f485de57d50f8a4e70f821022b1686ddd

+ 2 - 0
packages/llm/llm-pi-ai/README.md

@@ -116,6 +116,8 @@ A route pi-ai does not ship needs `api`, `baseURL`, and a non-empty `models` lis
 
 Settings writes strictly validate each new or changed provider after merging its composition and user layers. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first diagnostic in `LlmConfigurableProvider.error`; unchanged failed providers do not block edits elsewhere. Serviceable models remain selectable, while unresolved models remain in the editable configuration and fail with `INVALID_CONFIG` before network I/O if requested directly. Repairing or deleting the offending configuration clears its diagnostic. Schema and self-contained profile errors still reject loading. Later external edits validate changed providers and retain the last accepted section on failure.
 
+Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently.
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 2 - 0
packages/llm/llm-pi-ai/README.zh.md

@@ -116,6 +116,8 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无
 
 Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmConfigurableProvider.error` 返回首个诊断;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。
 
+只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 5 - 3
packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts

@@ -75,7 +75,8 @@ describe('login flows in a real composition', () => {
 })
 
 describe('request-level dynamic profiles', () => {
-  it('retains the last accepted profiles after an invalid external edit and accepts a repaired file', async () => {
+  // Real filesystem notifications can lag behind chokidar's stability window on busy hosts.
+  it('retains the last accepted profiles after an invalid external edit and accepts a repaired file', { timeout: 30_000 }, async () => {
     const dir = await home()
     const path = join(dir, 'settings.yaml')
     await writeFile(path, JSON.stringify({ [NS]: { providers: { deepseek: {} } } }))
@@ -83,14 +84,15 @@ describe('request-level dynamic profiles', () => {
 
     await writeFile(path, JSON.stringify({ [NS]: { providers: { openrouter: { models: [{ id: '111' }] } } } }))
     // The raw section proves the watcher processed the edit even though validation kept the old resolved value.
-    await expect.poll(() => ctx.settings.describe().find(section => section.ns === NS)?.user)
+    await expect.poll(() => ctx.settings.describe().find(section => section.ns === NS)?.user, { timeout: 10_000 })
       .toEqual({ providers: { openrouter: { models: [{ id: '111' }] } } })
     expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'deepseek' }])
 
     await writeFile(path, JSON.stringify({ [NS]: { providers: {
       openrouter: { api: 'openai-completions', models: [{ id: '111' }] },
     } } }))
-    await expect.poll(() => ctx.llm.listProviders()).toEqual([{ id: 'openrouter', name: 'openrouter' }])
+    await expect.poll(() => ctx.llm.listProviders(), { timeout: 10_000 })
+      .toEqual([{ id: 'openrouter', name: 'openrouter' }])
     expect((await ctx.llm.listModels('openrouter')).map(model => model.id)).toEqual(['111'])
   })