Переглянути джерело

fix(api): resolve review findings on stream boundary, inject staleness, and ctx discipline

- api/gateway: mark terminal Remote-stream escapes (carrier retry
  exhaustion and pre-acceptance end classification) as
  RemoteError('gateway/internal') at the two escape points; marked
  failures pass through verbatim. The carrier class stays the
  retry-internal signal for carrierFailed and the ended(true) retry
  trigger. Regression coverage lands on the session and workspace
  stream consumers.
- client/ui-tool, client/ui-workspace: read $host.home through a hooks
  observable subscribed to connection/reset; the slot renderer memoizes
  inject results per entry, so the previous plain-value injection froze
  home at the first render.
- client/ui-settings-models: components no longer receive ctx; apply
  binds the credential and settings Remote operations into callbacks,
  and the settings/conflict code judgment stays in the apply world.
imccyu 3 тижнів тому
батько
коміт
2f2e6d627b
27 змінених файлів з 451 додано та 111 видалено
  1. 19 2
      packages/api/gateway/src/client/remote-stream.ts
  2. 15 1
      packages/api/gateway/tests/control-retry.client.spec.ts
  3. 18 2
      packages/api/session-controller/tests/session.client.spec.ts
  4. 21 0
      packages/api/workspace-controller/tests/transport.client.spec.ts
  5. 10 12
      packages/client/ui-settings-models/src/client/CustomProviderCard.tsx
  6. 5 5
      packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx
  7. 8 8
      packages/client/ui-settings-models/src/client/ModelListEditor.tsx
  8. 19 19
      packages/client/ui-settings-models/src/client/ModelsSection.tsx
  9. 22 19
      packages/client/ui-settings-models/src/client/ProviderEditor.tsx
  10. 7 2
      packages/client/ui-settings-models/src/client/index.ts
  11. 109 0
      packages/client/ui-settings-models/src/client/operations.ts
  12. 2 2
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  13. 29 13
      packages/client/ui-settings-models/tests/components.client.spec.tsx
  14. 3 1
      packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx
  15. 23 7
      packages/client/ui-settings-models/tests/provider-form.client.spec.tsx
  16. 9 1
      packages/client/ui-tool/src/client/apply.ts
  17. 12 3
      packages/client/ui-tool/src/client/contract/slots.ts
  18. 2 1
      packages/client/ui-tool/src/client/tool/ToolCallTree.tsx
  19. 3 2
      packages/client/ui-tool/src/client/tool/ToolDetails.tsx
  20. 1 1
      packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
  21. 1 1
      packages/client/ui-tool/tests/tool-details-render.client.tsx
  22. 9 3
      packages/client/ui-workspace/src/client/contract/slots.ts
  23. 8 2
      packages/client/ui-workspace/src/client/index.ts
  24. 2 1
      packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
  25. 1 1
      packages/client/ui-workspace/tests/apply.client.spec.ts
  26. 91 0
      packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx
  27. 2 2
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

+ 19 - 2
packages/api/gateway/src/client/remote-stream.ts

@@ -1,5 +1,6 @@
 /** Reconnecting lifecycle for one single-consumer Remote stream. */
 
+import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 import { RemoteStreamCarrierError } from './stream-client.ts'
 
@@ -128,7 +129,7 @@ export class RemoteStream<Item> implements AsyncIterable<RemoteStreamItem<Item>>
         } catch (error) {
           if (isAborted(this.lifetime.signal)) return
           if (revision !== this.revision) continue
-          if (!(error instanceof RemoteStreamCarrierError)) throw error
+          if (!(error instanceof RemoteStreamCarrierError)) throw terminalStreamFailure(error)
           this.options.carrierFailed?.(error)
           if (revision !== this.revision) continue
           attempt++
@@ -137,7 +138,7 @@ export class RemoteStream<Item> implements AsyncIterable<RemoteStreamItem<Item>>
           } catch (retryError) {
             if (isAborted(this.lifetime.signal)) return
             if (revision !== this.revision) continue
-            throw retryError
+            throw terminalStreamFailure(retryError)
           }
         } finally {
           this.generationAbort = undefined
@@ -195,6 +196,22 @@ async function waitForRemoteStreamRetry(
   })
 }
 
+/**
+ * Mark a terminal escape before it crosses the stream boundary: consumers
+ * discriminate failures by code, so an unmarked throw reads as a local bug.
+ * Marked failures pass through verbatim. The carrier class never escapes as a
+ * terminal outcome — it stays the retry-internal signal fed to `carrierFailed`
+ * and the `ended(true)` retry trigger.
+ */
+function terminalStreamFailure(error: unknown): Error {
+  return remoteErrorOf(error) ?? new RemoteError(
+    'gateway/internal',
+    error instanceof Error ? error.message : String(error),
+    {},
+    { cause: error },
+  )
+}
+
 function isAborted(signal: AbortSignal): boolean {
   return signal.aborted
 }

+ 15 - 1
packages/api/gateway/tests/control-retry.client.spec.ts

@@ -1,4 +1,5 @@
 import { describe, expect, it, vi } from 'vitest'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
 import {
   RemoteStreamCarrierError,
@@ -106,11 +107,24 @@ describe('RemoteStream', () => {
       { terminal: repeated },
     ], carrierFailed)
 
-    await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(repeated)
+    await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({
+      isDSHRemoteGatewayError: true,
+      code: 'gateway/internal',
+      message: 'isolated retry failed',
+      details: {},
+      cause: repeated,
+    })
     expect(carrierFailed).toHaveBeenNthCalledWith(1, first)
     expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated)
   })
 
+  it('passes a marked Remote failure through the terminal boundary verbatim', async () => {
+    const failure = new RemoteError('gateway/internal', 'host stream failed', {})
+    const stream = supervisor(hostSource(true).connection, [{ terminal: failure }])
+
+    await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(failure)
+  })
+
   it('waits for a replacement Host generation after observing unavailability', async () => {
     let available = false
     let listener: (() => void) | undefined

+ 18 - 2
packages/api/session-controller/tests/session.client.spec.ts

@@ -3,6 +3,7 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
 import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
@@ -85,10 +86,25 @@ describe('Session open', () => {
     expect(snapshot.openError?.code).toBe('session/not-found')
   })
 
-  it('propagates a non-Remote throw raised while opening', async () => {
+  it('lands exhausted carrier retries in openState=error as gateway/internal', async () => {
+    const { api, session } = makeSession()
+    // Two consecutive carrier losses before any opening is accepted exhaust the
+    // Gateway's retry budget; the escaping failure crosses the stream boundary marked.
+    api.onHistory = () => Promise.reject(new RemoteStreamCarrierError('history carrier down'))
+    await session.open()
+    expect(session.getSnapshot().openState).toBe('error')
+    expect(session.getSnapshot().openError).toMatchObject({
+      code: 'gateway/internal', message: 'history carrier down',
+    })
+    expect(api.followStarts).toHaveLength(2)
+  })
+
+  it('lands a Gateway-marked stream failure in openState=error', async () => {
     const { api, session } = makeSession()
     api.onHistory = () => Promise.reject(new Error('socket died'))
-    await expect(session.open()).rejects.toThrow('socket died')
+    await session.open()
+    expect(session.getSnapshot().openState).toBe('error')
+    expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'socket died' })
   })
 
   it('stitches live frames arriving while history is pending, dropping the page overlap', async () => {

+ 21 - 0
packages/api/workspace-controller/tests/transport.client.spec.ts

@@ -231,6 +231,27 @@ describe('Workspace Controller Client apply', () => {
     expect(ctx.get('workspaces')).toBeUndefined()
   })
 
+  it('publishes exhausted carrier retries as a gateway/internal error state', async () => {
+    const ctx = new Context()
+    // Neither generation reaches an accepted baseline, so the retry budget runs
+    // out and the escaping carrier failure crosses the stream boundary marked.
+    const remote = new ScriptedWorkspaceRemote([
+      { frames: [], error: new RemoteStreamCarrierError('generation lost') },
+      { frames: [], error: new RemoteStreamCarrierError('generation lost again') },
+    ])
+    provideClientServices(ctx, remote)
+    const fiber = ctx.plugin(WorkspaceClientPlugin)
+    await fiber
+    await waitFor(() => {
+      expect(ctx.workspaces.list.getSnapshot()).toMatchObject({
+        state: 'error',
+        error: { code: 'gateway/internal', message: 'generation lost again' },
+      })
+    })
+    expect(remote.calls).toBe(2)
+    await fiber.dispose()
+  })
+
   it('marks carrier loss while retrying and publishes a later protocol failure', async () => {
     const ctx = new Context()
     const remote = new ScriptedWorkspaceRemote([

+ 10 - 12
packages/client/ui-settings-models/src/client/CustomProviderCard.tsx

@@ -23,7 +23,6 @@
 
 import { useState } from 'react'
 import type { ReactNode } from 'react'
-import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client'
 import { apiKeyFailure } from './apiKey.ts'
 import { EditorFooter } from './EditorFooter.tsx'
@@ -31,6 +30,7 @@ import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
 import { ModelListEditor } from './ModelListEditor.tsx'
 import type { ModelDraft } from './ModelListEditor.tsx'
 import { deriveKeyRef } from './store.ts'
+import type { ModelsOperations } from './operations.ts'
 import type { en } from './locales.ts'
 import styles from './ModelsSection.module.css'
 
@@ -59,8 +59,8 @@ export interface CustomProviderCardProps {
    * than a silent overwrite of its whole profile.
    */
   revision: number
-  /** The page plugin's context, whose Remote namespaces carry the write and the endpoint interrogation. */
-  ctx: ClientContext
+  /** The Host operations this card writes and interrogates through. */
+  operations: ModelsOperations
   /** Section copy. */
   t: (key: keyof typeof en) => string
   /** Disable writes (read-only settings provider). */
@@ -75,7 +75,7 @@ export interface CustomProviderCardProps {
  * @returns the creation card.
  */
 export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
-  const { taken, protocols, ctx, t } = props
+  const { taken, protocols, operations, t } = props
   // The write is checked against the revision on which this draft was opened.
   const [openedAt] = useState(() => props.revision)
   const [route, setRoute] = useState('')
@@ -147,15 +147,13 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
       // `taken` is a snapshot too, so the id check alone cannot see a route
       // declared after this card opened; the revision makes that race a
       // `settings-conflict` instead of a write over the other profile.
-      const response = await ctx.remote.settings.mutate(
+      const written = await operations.writeSettings(
         NS,
         [{ op: 'set', path: ['providers', route], value: profile as JsonValue }],
         openedAt,
       )
-      if (!response.ok) {
-        return response.error.code === 'settings/conflict'
-          ? t('conflict')
-          : response.error.message
+      if (written.kind !== 'written') {
+        return written.kind === 'conflict' ? t('conflict') : written.message
       }
       // The provider now exists. A retry after the key write below fails must
       // not re-run this mutate: the revision it holds is the one this write
@@ -164,10 +162,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
       setCommitted(true)
     }
     if (storesKey) {
-      const stored = await ctx.remote.credentials.set(keyRef, keyValue)
+      const stored = await operations.storeCredential(keyRef, keyValue)
       // The profile landed; saying the key did not is the only honest report,
       // and the retry above now goes straight back to this write.
-      if (!stored.ok) return stored.error.message
+      if (stored !== undefined) return stored
     }
     return undefined
   }
@@ -274,7 +272,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           ...keyValue.length === 0 ? {} : { apiKey: keyValue },
         }}
         probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
-        ctx={ctx}
+        operations={operations}
         t={t}
         disabled={profileDisabled}
       />

+ 5 - 5
packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx

@@ -8,11 +8,11 @@
 
 import { useEffect } from 'react'
 import type { ReactNode } from 'react'
-import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
 import { onboardingReadiness } from './store.ts'
+import type { ModelsOperations } from './operations.ts'
 import type { SettingsSchemaOperations } from './schema-operations.ts'
 import { ProviderEditor } from './ProviderEditor.tsx'
 import type { en } from './locales.ts'
@@ -27,8 +27,8 @@ export interface DeepSeekOnboardingInjected {
   }
   /** Shared Models-page join controller. */
   controller: ModelsSettingsStore
-  /** The plugin context the reused Models credential editor writes through. */
-  ctx: ClientContext
+  /** The Host operations the reused Models credential editor writes through. */
+  operations: ModelsOperations
   /** Settings schema and immutable path callbacks. */
   schema: SettingsSchemaOperations
   /** Feature copy. */
@@ -51,7 +51,7 @@ function assertNever(_value: never): never {
  * @returns the onboarding modal or null when onboarding needs no intervention.
  */
 export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
-  const { complete, controller, useModels, ctx, schema, t } = props
+  const { complete, controller, useModels, operations, schema, t } = props
   const state = useModels(snapshot => snapshot)
   const readiness = onboardingReadiness(state)
 
@@ -106,7 +106,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
           namespace={namespace}
           schema={schema}
           settingsPath={row.entry.settingsPath}
-          ctx={ctx}
+          operations={operations}
           t={t}
           readOnly={false}
           hideTitle

+ 8 - 8
packages/client/ui-settings-models/src/client/ModelListEditor.tsx

@@ -16,10 +16,10 @@
 
 import { useState } from 'react'
 import type { ReactNode } from 'react'
-import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-api-remotes/client'
 import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
 import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx'
+import type { ModelsOperations } from './operations.ts'
 import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
 import type { en } from './locales.ts'
 import styles from './ModelsSection.module.css'
@@ -79,8 +79,8 @@ export interface ModelListEditorProps {
    * told what the field already says.
    */
   probeBlocked?: keyof typeof en | undefined
-  /** The page plugin's context, whose `remote.llm` namespace answers the fetch action. */
-  ctx: ClientContext
+  /** The Host operations whose interrogation answers the fetch action. */
+  operations: ModelsOperations
   /** Section copy. */
   t: (key: keyof typeof en) => string
   /** Disable every control (read-only deployment or a pending write). */
@@ -157,7 +157,7 @@ function adopt(candidate: LlmDiscoveredModel): ModelDraft {
  * @returns the model-list editor.
  */
 export function ModelListEditor(props: ModelListEditorProps): ReactNode {
-  const { models, onChange, probe, ctx, t, disabled } = props
+  const { models, onChange, probe, operations, t, disabled } = props
   const [busy, setBusy] = useState(false)
   const [failure, setFailure] = useState<string | undefined>(undefined)
   const [candidates, setCandidates] = useState<readonly LlmDiscoveredModel[] | undefined>(undefined)
@@ -229,17 +229,17 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
     setBusy(true)
     setFailure(undefined)
     try {
-      const response = await ctx.remote.llm.discoverModels(probe.settingsNs, {
+      const answer = await operations.discoverModels(probe.settingsNs, {
         ...probe.provider === undefined ? {} : { provider: probe.provider },
         ...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL },
         ...probe.api === undefined ? {} : { api: probe.api },
         ...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey },
       })
-      if (!response.ok) {
-        setFailure(response.error.message)
+      if (answer.kind === 'refused') {
+        setFailure(answer.message)
         return
       }
-      const found = response.value
+      const found = answer.models
       if (found.length === 0) {
         setFailure(t('fetchEmpty'))
         return

+ 19 - 19
packages/client/ui-settings-models/src/client/ModelsSection.tsx

@@ -14,7 +14,6 @@
 
 import { useState } from 'react'
 import type { ReactNode } from 'react'
-import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: pulls this package's SlotMap merge (the two Models child slots).
@@ -22,6 +21,7 @@ import type {} from './slot-contract.ts'
 import { CustomProviderCard } from './CustomProviderCard.tsx'
 import { deriveKeyRef, protocolChoices, providerUsable } from './store.ts'
 import type { ModelsSettingsStore, ProviderRow } from './store.ts'
+import type { ModelsOperations } from './operations.ts'
 import type { SettingsSchemaOperations } from './schema-operations.ts'
 import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
 import type { en } from './locales.ts'
@@ -35,8 +35,8 @@ export interface ModelsSectionInjected {
     /** Page snapshot bound by the UI renderer as useSnapshot. */
     snapshot: ModelsSettingsStore['store']
   }
-  /** The page plugin's context, whose Remote namespaces the editors write through. */
-  ctx: ClientContext
+  /** The Host operations the section and its cards invoke. */
+  operations: ModelsOperations
   /** Settings schema and immutable path callbacks. */
   schema: SettingsSchemaOperations
   /** Section copy. */
@@ -81,7 +81,7 @@ interface EditorTarget extends ProviderIdentity {
 /** Values that vary around the shared provider-editor rendering. */
 interface ProviderEditorRenderProps extends Pick<
   ProviderEditorProps,
-  'namespace' | 'schema' | 'ctx' | 't' | 'readOnly' | 'onClose'
+  'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose'
 > {
   target: EditorTarget
 }
@@ -105,26 +105,26 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
  * and the whole operation safely retryable; both unsets are idempotent.
  * The settings removal names the profile rather than rebuilding its whole
  * namespace from a partial view.
- * @param ctx - the page plugin's context, carrying the settings and credential Remote namespaces.
+ * @param operations - the page's Host operations.
  * @param controller - the page store to refresh.
  * @param target - the provider's settings address and optional managed credential.
  * @returns the failure message, or undefined once the write and reload landed.
  */
 export async function removeProviderProfile(
-  ctx: ClientContext,
+  operations: ModelsOperations,
   controller: ModelsSettingsStore,
   target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string },
 ): Promise<string | undefined> {
   if (target.credentialRef !== undefined) {
-    const credential = await ctx.remote.credentials.unset(target.credentialRef)
-    if (!credential.ok) return credential.error.message
+    const credential = await operations.removeCredential(target.credentialRef)
+    if (credential !== undefined) return credential
   }
-  const response = await ctx.remote.settings.mutate(
+  const written = await operations.writeSettings(
     target.settingsNs,
     [{ op: 'unset', path: [...target.settingsPath] }],
     undefined,
   )
-  if (!response.ok) return response.error.message
+  if (written.kind !== 'written') return written.message
   await controller.load()
   return undefined
 }
@@ -193,16 +193,16 @@ export function providerCopy(template: string, target: ProviderIdentity): string
  * @returns the section, or null while the shell has not injected yet.
  */
 export function ModelsSection(props: ModelsSectionProps): ReactNode {
-  const { controller, useSnapshot, ctx, schema, t, renderSlot } = props
+  const { controller, useSnapshot, operations, schema, t, renderSlot } = props
   if (
-    controller === undefined || useSnapshot === undefined || ctx === undefined
+    controller === undefined || useSnapshot === undefined || operations === undefined
     || schema === undefined || t === undefined
   ) return null
-  return <Loaded injected={{ controller, useSnapshot, ctx, schema, t }} renderSlot={renderSlot} />
+  return <Loaded injected={{ controller, useSnapshot, operations, schema, t }} renderSlot={renderSlot} />
 }
 
 function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode {
-  const { controller, ctx, schema, t } = injected
+  const { controller, operations, schema, t } = injected
   const state = injected.useSnapshot(snapshot => snapshot)
   const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
   const [adding, setAdding] = useState(false)
@@ -250,7 +250,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
     if (deleteTarget === undefined || deleting) return
     setDeleting(true)
     setDeleteFailure(undefined)
-    void removeProviderProfile(ctx, controller, deleteTarget)
+    void removeProviderProfile(operations, controller, deleteTarget)
       .then((failure) => {
         if (failure !== undefined) {
           setDeleteFailure(failure)
@@ -331,7 +331,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
                   target,
                   namespace,
                   schema,
-                  ctx,
+                  operations,
                   t,
                   readOnly: !state.writable,
                   onClose: (changed) => { closeSetup(changed, target) },
@@ -426,7 +426,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
                   target,
                   namespace,
                   schema,
-                  ctx,
+                  operations,
                   t,
                   readOnly: !state.writable,
                   onClose: (changed) => { closeEditor(changed, target) },
@@ -466,7 +466,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
                 namespace={addNamespace}
                 schema={schema}
                 settingsPath={addTarget.settingsPath}
-                ctx={ctx}
+                operations={operations}
                 t={t}
                 readOnly={!state.writable}
                 onClose={(changed) => { closeEditor(changed, addTarget) }}
@@ -488,7 +488,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
                   protocols={protocols}
                   /* v8 ignore next -- the card only opens from a button disabled without this namespace */
                   revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0}
-                  ctx={ctx}
+                  operations={operations}
                   t={t}
                   readOnly={!state.writable}
                   onClose={(changed) => {

+ 22 - 19
packages/client/ui-settings-models/src/client/ProviderEditor.tsx

@@ -23,7 +23,6 @@
 
 import { useEffect, useMemo, useState } from 'react'
 import type { ReactNode } from 'react'
-import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {
   CredentialInfo, JsonValue, SettingsNamespaceView, SettingsPathOpView,
 } from '@deepseek-ai/dsh-api-remotes/client'
@@ -34,6 +33,7 @@ import { apiKeyFailure } from './apiKey.ts'
 import { EditorFooter } from './EditorFooter.tsx'
 import { ModelListEditor } from './ModelListEditor.tsx'
 import { deriveKeyRef, protocolChoices } from './store.ts'
+import type { ModelsOperations } from './operations.ts'
 import type { SettingsSchemaOperations } from './schema-operations.ts'
 import type { en } from './locales.ts'
 import styles from './ModelsSection.module.css'
@@ -66,8 +66,8 @@ export interface ProviderEditorProps {
   schema: SettingsSchemaOperations
   /** Path from the section root to this provider's profile. */
   settingsPath: readonly string[]
-  /** The page plugin's context, whose Remote namespaces carry the writes and the endpoint interrogation. */
-  ctx: ClientContext
+  /** The Host operations this card writes and interrogates through. */
+  operations: ModelsOperations
   /** Section copy. */
   t: (key: keyof typeof en) => string
   /** Disable writes (read-only settings provider). */
@@ -155,7 +155,7 @@ function refFor(
  * @returns the editor card.
  */
 export function ProviderEditor(props: ProviderEditorProps): ReactNode {
-  const { namespace, schema, settingsPath, ctx, t } = props
+  const { namespace, schema, settingsPath, operations, t } = props
   const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(schema, namespace, settingsPath))
   const [keyDraft, setKeyDraft] = useState('')
   const [keyState, setKeyState] = useState<CredentialInfo | undefined>(undefined)
@@ -188,12 +188,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
     setKeyState(undefined)
     // The key state is a placeholder hint, not a precondition for editing: a
     // refused describe leaves the card without the "already configured" hint.
-    void ctx.remote.credentials.describe([keyRef]).then((response) => {
-      if (stale || !response.ok) return
-      setKeyState(response.value[keyRef])
+    void operations.describeCredential(keyRef).then((described) => {
+      if (stale) return
+      setKeyState(described)
     })
     return () => { stale = true }
-  }, [ctx, keyRef])
+  }, [operations, keyRef])
 
   const stringAt = (source: unknown, key: string): string | undefined => {
     const value = schema.getPath(source, [key])
@@ -277,19 +277,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
         ? [{ op: 'set', path: [...settingsPath], value: {} }]
         : pathOps(settingsPath, committedOriginal, next)
     if (ops.length > 0) {
-      const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision)
-      if (!response.ok) {
-        return response.error.code === 'settings/conflict'
-          ? t('conflict')
-          : response.error.message
-      }
-      setCommittedOriginal(schema.getPath(response.value.user, settingsPath))
-      setExpectedRevision(response.value.revision)
+      const written = await operations.writeSettings(ns, ops, expectedRevision)
+      if (written.kind !== 'written') return written.kind === 'conflict' ? t('conflict') : written.message
+      setCommittedOriginal(schema.getPath(written.view.user, settingsPath))
+      setExpectedRevision(written.view.revision)
       setDraft(next)
     }
     if (keyValue.length > 0) {
-      const stored = await ctx.remote.credentials.set(keyRef, keyValue)
-      if (!stored.ok) return stored.error.message
+      const stored = await operations.storeCredential(keyRef, keyValue)
+      if (stored !== undefined) return stored
     }
     setKeyDraft('')
     return undefined
@@ -464,7 +460,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
                   defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
                 />
               )
-              : <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} ctx={ctx} />}
+              : (
+                <ModelListEditor
+                  {...catalogProps}
+                  probe={probe}
+                  probeBlocked={keyFailure}
+                  operations={operations}
+                />
+              )}
           </div>
         </details>}
       </>

+ 7 - 2
packages/client/ui-settings-models/src/client/index.ts

@@ -23,6 +23,7 @@ import { WelcomeNotice } from './WelcomeNotice.tsx'
 import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
 import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts'
 import { ModelsSettingsStore } from './store.ts'
+import { createModelsOperations } from './operations.ts'
 import { createSettingsSchemaOperations } from './schema-operations.ts'
 import { en, zh, type ModelsKey } from './locales.ts'
 import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
@@ -43,6 +44,7 @@ const NS = 'settings.models'
 export type {
   ModelsSettingsState, ProviderDirectoryEntry, ProviderRow,
 } from './store.ts'
+export type { ModelDiscoveryOutcome, ModelsOperations, SettingsWriteOutcome } from './operations.ts'
 
 /**
  * Refetch the page snapshot only after its first load: an unopened Models
@@ -74,6 +76,9 @@ export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries')
 
   const schema = createSettingsSchemaOperations(ctx.settingsSchema)
+  // Bound once here, where the Remote namespaces are declared in this plugin's
+  // own `inject`; the cards receive callbacks and never a context.
+  const operations = createModelsOperations(ctx)
   const controller = new ModelsSettingsStore(ctx, schema, ctx.settingsScope.describe())
   // Registration-time text (the nav label thunk) and the inject faces share
   // one bound translate; copy freshness rides the locale revision.
@@ -81,14 +86,14 @@ export function apply(ctx: ClientContext): void {
   const injected = (): ModelsSectionInjected => ({
     controller,
     hooks: { snapshot: controller.store },
-    ctx,
+    operations,
     schema,
     t,
   })
   const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({
     controller,
     hooks: { models: controller.store },
-    ctx,
+    operations,
     schema,
     t,
   })

+ 109 - 0
packages/client/ui-settings-models/src/client/operations.ts

@@ -0,0 +1,109 @@
+/**
+ * The Host reads and writes the Models cards perform, as callbacks built in the
+ * plugin body. Cards receive these instead of a context: the outcomes name what
+ * a card renders — a stored view, a stale revision, a refusal message — so the
+ * failure codes and Remote namespaces stay in the apply world.
+ */
+
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {
+  CredentialInfo, LlmDiscoveredModel, LlmModelDiscoveryRequest,
+  SettingsNamespaceView, SettingsPathOpView,
+} from '@deepseek-ai/dsh-api-remotes/client'
+
+/** What one namespace write answered. */
+export type SettingsWriteOutcome =
+  /** Committed; the view carries the stored user subtree and the new revision. */
+  | { readonly kind: 'written'; readonly view: SettingsNamespaceView }
+  /**
+   * The stored revision moved after the card read it, so the draft is stale.
+   * The message stays for callers that report the Host diagnostic as it is.
+   */
+  | { readonly kind: 'conflict'; readonly message: string }
+  /** Any other refusal, with the Host's own diagnostic. */
+  | { readonly kind: 'refused'; readonly message: string }
+
+/** What one endpoint interrogation answered. */
+export type ModelDiscoveryOutcome =
+  /** The candidates the provider disclosed, in its own order. */
+  | { readonly kind: 'found'; readonly models: readonly LlmDiscoveredModel[] }
+  /** The interrogation was refused, with the Host's own diagnostic. */
+  | { readonly kind: 'refused'; readonly message: string }
+
+/** The Host operations the Models page and its cards invoke. */
+export interface ModelsOperations {
+  /**
+   * Read one credential reference's state.
+   * @param ref - credential reference name.
+   * @returns the state, or undefined when the reference is unknown or the read was refused.
+   */
+  describeCredential(ref: string): Promise<CredentialInfo | undefined>
+  /**
+   * Store one credential literal under its reference.
+   * @param ref - credential reference name.
+   * @param value - the literal to store.
+   * @returns the refusal message, or undefined once stored.
+   */
+  storeCredential(ref: string, value: string): Promise<string | undefined>
+  /**
+   * Remove one credential reference (idempotent).
+   * @param ref - credential reference name.
+   * @returns the refusal message, or undefined once removed.
+   */
+  removeCredential(ref: string): Promise<string | undefined>
+  /**
+   * Apply path operations to one settings namespace.
+   * @param ns - settings namespace identity.
+   * @param ops - ordered path operations against the stored section, as the
+   * wire takes them (the Remote signature owns the array).
+   * @param expectedRevision - revision the draft was opened at, or undefined to write unfenced.
+   * @returns the write outcome the card renders from.
+   */
+  writeSettings(
+    ns: string,
+    ops: SettingsPathOpView[],
+    expectedRevision: number | undefined,
+  ): Promise<SettingsWriteOutcome>
+  /**
+   * Ask a provider endpoint what models it serves.
+   * @param settingsNs - namespace whose adapter family answers.
+   * @param request - endpoint facts as the form currently shows them.
+   * @returns the candidates, or the refusal.
+   */
+  discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise<ModelDiscoveryOutcome>
+}
+
+/**
+ * Bind the page's Host operations to the plugin's own Remote namespaces.
+ * @param ctx - the page plugin's context, which declares `remote.credentials`,
+ * `remote.llm`, and `remote.settings` in its own `inject`.
+ * @returns the callbacks the section and its cards are injected with.
+ */
+export function createModelsOperations(ctx: ClientContext): ModelsOperations {
+  return {
+    describeCredential: async (ref) => {
+      const response = await ctx.remote.credentials.describe([ref])
+      return response.ok ? response.value[ref] : undefined
+    },
+    storeCredential: async (ref, value) => {
+      const response = await ctx.remote.credentials.set(ref, value)
+      return response.ok ? undefined : response.error.message
+    },
+    removeCredential: async (ref) => {
+      const response = await ctx.remote.credentials.unset(ref)
+      return response.ok ? undefined : response.error.message
+    },
+    writeSettings: async (ns, ops, expectedRevision) => {
+      const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision)
+      if (response.ok) return { kind: 'written', view: response.value }
+      const { code, message } = response.error
+      return code === 'settings/conflict' ? { kind: 'conflict', message } : { kind: 'refused', message }
+    },
+    discoverModels: async (settingsNs, request) => {
+      const response = await ctx.remote.llm.discoverModels(settingsNs, request)
+      return response.ok
+        ? { kind: 'found', models: response.value }
+        : { kind: 'refused', message: response.error.message }
+    },
+  }
+}

+ 2 - 2
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -85,7 +85,7 @@ describe('ui-settings-models apply', () => {
     expect(injected.t('deleteTitle')).toBe('删除 {provider}?')
     expect(typeof injected.controller.load).toBe('function')
     expect(injected.hooks.snapshot).toBe(injected.controller.store)
-    expect(injected.ctx).toBeDefined()
+    expect(typeof injected.operations.writeSettings).toBe('function')
     const onboarding = before.slots.entries('settings.onboarding')
     expect(onboarding).toHaveLength(2)
     expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({
@@ -99,7 +99,7 @@ describe('ui-settings-models apply', () => {
       deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
     )()
     expect(deepSeekInjected.hooks.models).toBe(injected.controller.store)
-    expect(deepSeekInjected.ctx).toBeDefined()
+    expect(typeof deepSeekInjected.operations.storeCredential).toBe('function')
 
     const after = await bench()
     await after.ctx.plugin({ inject: [...inject], apply }).await()

+ 29 - 13
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -18,6 +18,8 @@ import {
 import { apiKeyFailure } from '../src/client/apiKey.ts'
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
+import { createModelsOperations } from '../src/client/operations.ts'
+import type { ModelsOperations } from '../src/client/operations.ts'
 import type { ProviderRow } from '../src/client/store.ts'
 import { en } from '../src/client/locales.ts'
 import { settingsSchema } from './settings-schema.client.ts'
@@ -220,6 +222,20 @@ function ctxWith(face: object): PageContext {
   return ctx
 }
 
+/**
+ * The cards' injected Host operations over the same script, bound once per face
+ * as the plugin body binds them: an editor effect keyed by this face would
+ * otherwise re-probe on every render.
+ */
+const operations = new WeakMap<object, ModelsOperations>()
+function operationsWith(face: object): ModelsOperations {
+  const existing = operations.get(face)
+  if (existing !== undefined) return existing
+  const bound = createModelsOperations(ctxWith(face))
+  operations.set(face, bound)
+  return bound
+}
+
 /** One recorded child-slot dispatch: seat name, owner share, kind options. */
 type RenderSlotCall = [name: string, owner: Record<string, unknown>, opts?: { entryKey?: string }]
 
@@ -252,7 +268,7 @@ async function mountFace(scripted: ReturnType<typeof scriptedFace>) {
   const injected: ModelsSectionProps = {
     controller,
     useSnapshot: bindSnapshotSelector(controller.store),
-    ctx,
+    operations: operationsWith(face),
     schema: settingsSchema,
     t,
     renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'],
@@ -387,7 +403,7 @@ describe('ModelsSection', () => {
     render(<ModelsSection
       controller={controller}
       useSnapshot={bindSnapshotSelector(controller.store)}
-      ctx={ctxWith(face)}
+      operations={operationsWith(face)}
       schema={settingsSchema}
       t={t}
       renderSlot={() => null}
@@ -412,7 +428,7 @@ describe('ModelsSection', () => {
     render(<ModelsSection
       controller={controller}
       useSnapshot={bindSnapshotSelector(controller.store)}
-      ctx={ctxWith(face)}
+      operations={operationsWith(face)}
       schema={settingsSchema}
       t={t}
       renderSlot={() => null}
@@ -494,7 +510,7 @@ describe('ModelsSection', () => {
       namespace={wireNamespaces()[0]!}
       schema={settingsSchema}
       settingsPath={[]}
-      ctx={ctxWith(face)}
+      operations={operationsWith(face)}
       t={t}
       readOnly={false}
       credentialOnly
@@ -747,7 +763,7 @@ describe('ModelsSection', () => {
       namespace={overridden}
       schema={settingsSchema}
       settingsPath={[]}
-      ctx={ctxWith(face)}
+      operations={operationsWith(face)}
       t={t}
       readOnly={false}
       onClose={() => {}}
@@ -977,7 +993,7 @@ describe('ModelsSection', () => {
       namespace={bare}
       schema={settingsSchema}
       settingsPath={[]}
-      ctx={ctxWith(face)}
+      operations={operationsWith(face)}
       t={t}
       readOnly={false}
       onClose={() => {}}
@@ -1138,7 +1154,7 @@ describe('ModelsSection', () => {
     render(<ModelsSection
       controller={controller}
       useSnapshot={bindSnapshotSelector(controller.store)}
-      ctx={ctxWith(face)}
+      operations={operationsWith(face)}
       schema={settingsSchema}
       t={t}
       renderSlot={() => null}
@@ -1272,7 +1288,7 @@ describe('ModelsSection', () => {
     render(<ModelsSection
       controller={controller}
       useSnapshot={bindSnapshotSelector(controller.store)}
-      ctx={ctxWith(face.face)}
+      operations={operationsWith(face.face)}
       schema={settingsSchema}
       t={t}
       renderSlot={() => null}
@@ -1295,7 +1311,7 @@ describe('ModelsSection', () => {
     render(<ModelsSection
       controller={controller}
       useSnapshot={bindSnapshotSelector(controller.store)}
-      ctx={ctxWith(face)}
+      operations={operationsWith(face)}
       schema={settingsSchema}
       t={t}
       renderSlot={() => null}
@@ -1357,7 +1373,7 @@ describe('ModelsSection', () => {
     render(<ModelsSection
       controller={controller}
       useSnapshot={bindSnapshotSelector(controller.store)}
-      ctx={ctxWith(face)}
+      operations={operationsWith(face)}
       schema={settingsSchema}
       t={t}
       renderSlot={() => null}
@@ -1370,7 +1386,7 @@ describe('ModelsSection', () => {
     // would widen the write for no benefit.
     const { face, mutate, controller } = await mountSection()
     await removeProviderProfile(
-      ctxWith(face),
+      operationsWith(face),
       controller,
       { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
     )
@@ -1387,7 +1403,7 @@ describe('ModelsSection', () => {
     })
     const before = controller.store.getSnapshot().rows
     const failure = await removeProviderProfile(
-      ctxWith(face),
+      operationsWith(face),
       controller,
       { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
     )
@@ -1438,7 +1454,7 @@ describe('ModelsSection', () => {
       unset: vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))),
     })
     const failure = await removeProviderProfile(
-      ctxWith(face),
+      operationsWith(face),
       controller,
       {
         settingsNs: 'llm-pi-ai',

+ 3 - 1
packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx

@@ -9,6 +9,7 @@ import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog
 import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx'
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { ModelsSettingsStore } from '../src/client/store.ts'
+import { createModelsOperations } from '../src/client/operations.ts'
 import { en } from '../src/client/locales.ts'
 import { settingsSchema } from './settings-schema.client.ts'
 
@@ -130,6 +131,7 @@ function harness(options: {
   }
   // The page plugin's context, scripted down to the namespaces it reaches.
   const ctx = { remote: face } as never
+  const operations = createModelsOperations(ctx)
   const controller = new ModelsSettingsStore(ctx, settingsSchema, new SettingsDescribeMirror(ctx))
   const openSection = vi.fn()
   const complete = vi.fn()
@@ -143,7 +145,7 @@ function harness(options: {
     useWorkspaces: unusedHook,
     controller,
     useModels: bindSnapshotSelector(controller.store),
-    ctx,
+    operations,
     schema: settingsSchema,
     t: key => en[key],
   }

+ 23 - 7
packages/client/ui-settings-models/tests/provider-form.client.spec.tsx

@@ -11,6 +11,8 @@ import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
 import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts'
+import { createModelsOperations } from '../src/client/operations.ts'
+import type { ModelsOperations } from '../src/client/operations.ts'
 import { en } from '../src/client/locales.ts'
 import { settingsSchema } from './settings-schema.client.ts'
 
@@ -155,6 +157,20 @@ function ctxWith(face: object): PageContext {
   return ctx
 }
 
+/**
+ * The cards' injected Host operations over the same script, bound once per face
+ * as the plugin body binds them: an editor effect keyed by this face would
+ * otherwise re-probe on every render.
+ */
+const operations = new WeakMap<object, ModelsOperations>()
+function operationsWith(face: object): ModelsOperations {
+  const existing = operations.get(face)
+  if (existing !== undefined) return existing
+  const bound = createModelsOperations(ctxWith(face))
+  operations.set(face, bound)
+  return bound
+}
+
 /** The settings write one card produced, as the scripted face recorded it. */
 interface MutateCall {
   ns: string
@@ -189,7 +205,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
   const injected: ModelsSectionProps = {
     controller,
     useSnapshot: bindSnapshotSelector(controller.store),
-    ctx: ctxWith(scripted.face),
+    operations: operationsWith(scripted.face),
     schema: settingsSchema,
     t,
     renderSlot: () => null,
@@ -576,7 +592,7 @@ describe('endpoint interrogation', () => {
     const scripted = scriptedFace()
     render(
       <CustomProviderCard
-        taken={[]} protocols={PROTOCOLS} revision={7} ctx={ctxWith(scripted.face)}
+        taken={[]} protocols={PROTOCOLS} revision={7} operations={operationsWith(scripted.face)}
         t={t} readOnly={false} onClose={vi.fn()}
       />,
     )
@@ -701,7 +717,7 @@ describe('provider rows', () => {
     render(<ModelsSection
       controller={controller}
       useSnapshot={bindSnapshotSelector(controller.store)}
-      ctx={ctxWith(scripted.face)}
+      operations={operationsWith(scripted.face)}
       schema={settingsSchema}
       t={t}
       renderSlot={() => null}
@@ -725,7 +741,7 @@ describe('hand-declared providers', () => {
         taken={['openai']}
         protocols={PROTOCOLS}
         revision={7}
-        ctx={ctxWith(scripted.face)}
+        operations={operationsWith(scripted.face)}
         t={t}
         readOnly={false}
         onClose={onClose}
@@ -1152,7 +1168,7 @@ describe('hand-declared providers', () => {
 
   it('surfaces a refused write without closing', async () => {
     const refused = vi.fn(() => Promise.resolve(remoteFail('read-only settings', 'settings/rejected')))
-    const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: refused }).face) })
+    const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ mutate: refused }).face) })
 
     fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
     fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
@@ -1166,7 +1182,7 @@ describe('hand-declared providers', () => {
 
   it('translates a create refused by a newer namespace revision', async () => {
     const conflicting = vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings/conflict')))
-    const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: conflicting }).face) })
+    const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ mutate: conflicting }).face) })
 
     fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
     fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
@@ -1180,7 +1196,7 @@ describe('hand-declared providers', () => {
 
   it('reports a stored profile whose key write was refused', async () => {
     const set = vi.fn(() => Promise.resolve(remoteFail('credential is read-only')))
-    const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ set }).face) })
+    const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ set }).face) })
 
     fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
     fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })

+ 9 - 1
packages/client/ui-tool/src/client/apply.ts

@@ -1,5 +1,6 @@
 /** Register the Tool call tree, details renderer, and built-in atomic views. */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -23,7 +24,14 @@ export const inject = ['slots', 'remote']
  * @param ctx - Client root context.
  */
 export function apply(ctx: ClientContext): void {
-  const toolInject = () => ({ home: ctx.remote.$host.home })
+  // Host facts are plain reads; a reset is what announces the generation that
+  // published them, so the views re-read on it instead of freezing the value
+  // the entry's first render saw (inject results are memoized per registration).
+  const hostHome: HostObservable<string | undefined> = {
+    getSnapshot: () => ctx.remote.$host.home,
+    subscribe: listener => ctx.on('connection/reset', listener),
+  }
+  const toolInject = () => ({ hooks: { hostHome } })
   ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
     name: 'conversation.chat.node',
     key: 'tool-call',

+ 12 - 3
packages/client/ui-tool/src/client/contract/slots.ts

@@ -1,5 +1,7 @@
 /** Tool UI slot declarations and their composed component props. */
-import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+  HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
+} from '@deepseek-ai/dsh-client-ui-slots'
 import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -47,8 +49,15 @@ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
 
 /** Injected Host description for POSIX home-path display. */
 export type ToolHostHomeInjected = {
-  /** Host account home, absent until the Connection is ready. */
-  home: string | undefined
+  hooks: {
+    /**
+     * Host account home, absent until the Connection is ready. A hook rather
+     * than a value: the renderer memoizes an entry's inject result for the
+     * registration's lifetime, so a home read there would freeze at whatever
+     * the first render saw.
+     */
+    hostHome: HostObservable<string | undefined>
+  }
 }
 
 /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */

+ 2 - 1
packages/client/ui-tool/src/client/tool/ToolCallTree.tsx

@@ -93,8 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({
  * @returns the Tool call tree.
  */
 export function ToolCallTree({
-  renderSlot, node, selectedCallId, cwd, openFile, inspectCall, home, t,
+  renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostHome, t,
 }: ToolTreeProps) {
+  const home = useHostHome(value => value)
   const block = node.data.root
   return (
     <ToolCallBranch

+ 3 - 2
packages/client/ui-tool/src/client/tool/ToolDetails.tsx

@@ -21,8 +21,9 @@ import css from './ToolDetails.module.css'
  * @returns the details output body.
  */
 export function ToolDetails({
-  block, cwd, home, t,
-}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'home' | 't'>) {
+  block, cwd, useHostHome, t,
+}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useHostHome' | 't'>) {
+  const home = useHostHome(value => value)
   const terminalModel = terminalCardModel(block, cwd)
   if (terminalModel !== null) {
     const terminal = localizeTerminalCardModel(terminalModel, t)

+ 1 - 1
packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx

@@ -49,7 +49,7 @@ function props(
     inspectCall: vi.fn(),
     forkAt: vi.fn(),
     fileMentions: vi.fn(),
-    home,
+    useHostHome: ((selector: (value: string | undefined) => unknown) => selector(home)) as ToolTreeProps['useHostHome'],
     t,
   } as unknown as ToolTreeProps
 }

+ 1 - 1
packages/client/ui-tool/tests/tool-details-render.client.tsx

@@ -157,7 +157,7 @@ export function renderToolDetails(
     return <ToolDetails
       block={details.block}
       cwd={details.cwd}
-      home={home}
+      useHostHome={selector => selector(home)}
       t={t}
     />
   }

+ 9 - 3
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -87,9 +87,15 @@ export type DirectoryPickingHooks = PropsHooks<DirectoryPickingInjected['hooks']
  * browsing region drives.
  */
 export type WorkspaceBrowserInjected = {
-  hooks: DirectoryPickingInjected['hooks']
-  /** Host account home, absent until the Connection is ready. */
-  home: string | undefined
+  hooks: DirectoryPickingInjected['hooks'] & {
+    /**
+     * Host account home, absent until the Connection is ready. A hook rather
+     * than a value: the renderer memoizes an entry's inject result for the
+     * registration's lifetime, so a home read there would freeze at whatever
+     * the first render saw.
+     */
+    hostHome: HostObservable<string | undefined>
+  }
   /**
    * Start a New Session in a Workspace: reuse-or-create its blank session and
    * open it; without an explicit workspace, inherit the current Session

+ 8 - 2
packages/client/ui-workspace/src/client/index.ts

@@ -89,6 +89,13 @@ export function apply(ctx: Context): void {
     subscribe: listener => ctx.slots.subscribe(hole, listener),
   })
   const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow')
+  // Host facts are plain reads; a reset is what announces the generation that
+  // published them, so the rows re-read on it instead of freezing the value
+  // the entry's first render saw (inject results are memoized per registration).
+  const hostHome: HostObservable<string | undefined> = {
+    getSnapshot: () => ctx.remote.$host.home,
+    subscribe: listener => ctx.on('connection/reset', listener),
+  }
   const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow')
   const browserInjected = (): WorkspaceBrowserInjected => ({
     // Explicit group actions keep their target; unscoped New Session inherits
@@ -122,8 +129,7 @@ export function apply(ctx: Context): void {
       await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
     },
     createWorkspace: input => workspaces.create(input),
-    home: ctx.remote.$host.home,
-    hooks: { directoryFlow: browserFlowSource },
+    hooks: { directoryFlow: browserFlowSource, hostHome },
   })
   const pickerInjected = (): WorkspacePickerInjected => ({
     createWorkspace: input => workspaces.create(input),

+ 2 - 1
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -820,10 +820,11 @@ export function WorkspaceBrowser({
   searchSessions,
   searchResultLimit,
   useDirectoryFlow,
-  home,
+  useHostHome,
   renderSlot,
   t,
 }: WorkspaceBrowserProps) {
+  const home = useHostHome(value => value)
   const workspaces = useWorkspaces(state => state.items)
   const workspacePhase = useWorkspaces(state => state.phase)
   const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)

+ 1 - 1
packages/client/ui-workspace/tests/apply.client.spec.ts

@@ -159,7 +159,7 @@ describe('ui-workspace apply', () => {
     const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
     const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
     expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
-    expect(browser.home).toBeUndefined()
+    expect(browser.hooks.hostHome.getSnapshot()).toBeUndefined()
     expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
     // A flow occupant flips exactly its own surface, and the source notifies.
     const notified = vi.fn()

+ 91 - 0
packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx

@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+/**
+ * Host home reaches the browsing region through the assembled renderer, which
+ * memoizes a root entry's inject result for the whole registration — so a home
+ * read once at first render would freeze there. This spec drives the real slot
+ * renderer (not a direct `entry.inject()` call, which bypasses that memo) and
+ * pins that a home learned after first render reaches the rendered rows.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, screen } from '@testing-library/react'
+import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
+
+usePinnedBrowserLanguages('zh-CN')
+
+afterEach(cleanup)
+beforeEach(() => { localStorage.clear() })
+
+/** Test-owned sidebar shell role: declares and renders the browsing region. */
+type FrameProps = PropsRenderSlots<'sidebar.workspaces'>
+function SidebarFrame({ renderSlot }: FrameProps) {
+  return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })}</>
+}
+
+/** The assembled sidebar over one Workspace inside the POSIX home the Host reports. */
+async function bench() {
+  const runtime = await SlotTestRuntime.create()
+  runtime.releaseWorkspaceSource()
+  const directoryPicker = {}
+  const remote = new TestRemote(runtime.ctx)
+  Object.assign(remote, { directoryPicker })
+  runtime.ctx.provide('remote.directoryPicker', directoryPicker as never)
+  const locale = new LocaleRuntime(runtime.ctx)
+  runtime.ctx.provide('locale', locale)
+  runtime.slots.installLocale(locale)
+  await runtime.workspaces.update((draft) => {
+    draft.items = [{
+      workspaceId: 'w1' as WorkspaceId, title: 'Project', path: '/home/u/Documents/project',
+      sessionIds: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
+    }] as never
+  })
+  await runtime.root.declare(
+    { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
+    SidebarFrame as never,
+  )
+  await runtime.mount({ inject: [...inject], apply })
+  return { runtime, remote }
+}
+
+/** Open the Workspace row's hover card, which is where the home abbreviation shows. */
+function openHoverCard(): void {
+  const row = screen.getByRole('treeitem').parentElement as HTMLElement
+  fireEvent.pointerEnter(row)
+  act(() => { vi.advanceTimersByTime(500) })
+}
+
+/** Close it again, so the next hover rebuilds the card from current props. */
+function closeHoverCard(): void {
+  const row = screen.getByRole('treeitem').parentElement as HTMLElement
+  fireEvent.pointerLeave(row)
+  act(() => { vi.advanceTimersByTime(500) })
+}
+
+describe('Host home in the assembled browsing region', () => {
+  it('abbreviates the path once a home learned after first render reaches the rows', async () => {
+    // First render precedes the ready frame: the shell mounts while the carrier
+    // is still handshaking, so the Host reports no home yet.
+    const { runtime, remote } = await bench()
+    remote.$host = { home: undefined, isLoopback: true }
+    runtime.renderRoot()
+    vi.useFakeTimers()
+    try {
+      openHoverCard()
+      expect(screen.getByText('/home/u/Documents/project')).toBeTruthy()
+      closeHoverCard()
+
+      // The ready frame lands: `$host.home` now answers, and the generation is
+      // announced through the reset every consumer already listens to.
+      remote.$host = { home: '/home/u', isLoopback: true }
+      act(() => { runtime.ctx.emit('connection/reset') })
+      openHoverCard()
+
+      expect(screen.getByText('~/Documents/project')).toBeTruthy()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})

+ 2 - 2
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -84,7 +84,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     insertSessionBefore: vi.fn(async () => {}),
     createWorkspace: vi.fn(async () => workspace('created', [])),
     useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
-    home: undefined,
+    useHostHome: selector => selector(undefined),
     renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
     t,
     ...overrides,
@@ -109,7 +109,7 @@ describe('WorkspaceBrowser', () => {
           path: '/home/u/Documents/project',
           title: 'Project',
         }])),
-        home: '/home/u',
+        useHostHome: selector => selector('/home/u'),
       })
       fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
       act(() => { vi.advanceTimersByTime(500) })