Sfoglia il codice sorgente

refactor(client): replace host description consumers

imccyu 3 settimane fa
parent
commit
40929d6e1a
40 ha cambiato i file con 283 aggiunte e 198 eliminazioni
  1. 2 4
      apps/web/tests/agent-preset-authoring.overlay.yml
  2. 1 1
      apps/web/tests/produced-files.overlay.yml
  3. 18 1
      packages/api/session-controller/src/index.ts
  4. 15 40
      packages/api/session-controller/tests/client-apply.client.spec.ts
  5. 4 17
      packages/api/session-controller/tests/fake-api.client.ts
  6. 33 0
      packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts
  7. 14 4
      packages/api/session-controller/tests/test-remote.ts
  8. 9 0
      packages/api/settings-controller/src/index.ts
  9. 3 0
      packages/api/settings-controller/tests/settings-controller.host.spec.ts
  10. 0 11
      packages/api/workspace-controller/tests/transport.client.spec.ts
  11. 2 4
      packages/client/ui-agent-preset/src/client/index.ts
  12. 3 4
      packages/client/ui-agent-preset/src/client/section-store.ts
  13. 3 11
      packages/client/ui-agent-preset/tests/apply.client.spec.ts
  14. 12 28
      packages/client/ui-agent-preset/tests/section-store.client.spec.ts
  15. 4 0
      packages/client/ui-deliverables/package.json
  16. 9 7
      packages/client/ui-deliverables/src/client/ProducedFiles.tsx
  17. 33 2
      packages/client/ui-deliverables/src/client/index.ts
  18. 69 11
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  19. 6 0
      packages/client/ui-deliverables/tsconfig.json
  20. 1 1
      packages/client/ui-reference/src/client/index.ts
  21. 2 2
      packages/client/ui-reference/tests/browser-plugin.client.spec.ts
  22. 1 1
      packages/client/ui-tool/src/client/apply.ts
  23. 6 6
      packages/client/ui-tool/src/client/contract/slots.ts
  24. 1 1
      packages/client/ui-tool/src/client/index.ts
  25. 2 2
      packages/client/ui-tool/src/client/tool/ToolCallTree.tsx
  26. 3 3
      packages/client/ui-tool/src/client/tool/ToolDetails.tsx
  27. 1 1
      packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx
  28. 2 2
      packages/client/ui-tool/tests/ask-question-row.client.spec.tsx
  29. 1 2
      packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx
  30. 1 2
      packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx
  31. 1 3
      packages/client/ui-tool/tests/read-card.client.spec.tsx
  32. 4 6
      packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
  33. 4 4
      packages/client/ui-tool/tests/tool-details-render.client.tsx
  34. 2 4
      packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
  35. 2 2
      packages/client/ui-workspace/src/client/contract/slots.ts
  36. 2 2
      packages/client/ui-workspace/src/client/index.ts
  37. 2 2
      packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
  38. 2 2
      packages/client/ui-workspace/tests/apply.client.spec.ts
  39. 1 1
      packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx
  40. 2 4
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

+ 2 - 4
apps/web/tests/agent-preset-authoring.overlay.yml

@@ -1,14 +1,12 @@
 # The authoring lane drives the location affordance. A real desktop open
 # would pop a file manager on the machine running the tests and the
 # capability itself is platform-detected (macOS yes, headless Linux CI no),
-# so the gateway is pinned headless: `hasDocument` is false everywhere and
+# so both native-open owners are pinned headless: `hasDocument` is false everywhere and
 # `openDocument` answers the directory as text — the same branch on every
 # host, and the one whose rendering a golden can hold. A patch replaces the
 # row's complete config, so the shipped routing defaults ride along.
-- id: api-gateway
+- id: session-controller
   config:
-    provider: deepseek-official
-    model: deepseek-v4-flash
     nativeOpen: false
 - id: settings-controller
   config:

+ 1 - 1
apps/web/tests/produced-files.overlay.yml

@@ -1,7 +1,7 @@
 # The summary test asserts the native-folder action without launching it. Pin
 # the capability so headless Linux CI and desktop developer hosts expose the
 # same UI branch; platform opener behavior belongs to the Host unit tests.
-- id: api-gateway
+- id: session-controller
   config:
     nativeOpen: true
 - id: settings-controller

+ 18 - 1
packages/api/session-controller/src/index.ts

@@ -3,7 +3,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { errorChain } from '@deepseek-ai/dsh-llm'
-import { openNativePath } from '@deepseek-ai/dsh-native-command'
+import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
 import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
@@ -67,12 +67,16 @@ declare module '@deepseek-ai/cordis' {
 export interface Config {
   /** Maximum cold Session artifact size eligible for one full projection observation. */
   readonly coldBlankProbeMaxBytes?: number
+  /** Override platform desktop-opener detection. */
+  readonly nativeOpen?: boolean
 }
 
 /** Host integrations replaceable by direct unit tests. */
 export interface SessionControllerInternals {
   /** Native default-application handoff. */
   readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
+  /** Native handoff availability probe. */
+  readonly canOpenPath?: () => boolean
 }
 
 /** Host service backing the generated `ctx.remote.session` namespace. */
@@ -91,6 +95,7 @@ export class SessionController extends TypertRemoteService {
 
   static Config: z<Config> = z.object({
     coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
+    nativeOpen: z.boolean(),
   })
 
   private readonly agents: ApiSessionAgentController
@@ -99,6 +104,7 @@ export class SessionController extends TypertRemoteService {
   private readonly history: SessionHistoryController
   private readonly listState: ApiSessionList
   private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
+  private readonly canOpenPath: () => boolean
   private readonly promotions = new Set<Promise<void>>()
 
   /**
@@ -122,6 +128,8 @@ export class SessionController extends TypertRemoteService {
       config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
     )
     this.openPath = internals.openPath ?? openNativePath
+    this.canOpenPath = internals.canOpenPath
+      ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
     ctx.plugin(SessionFileReferences)
     ctx.plugin(SessionSkillCatalog)
 
@@ -242,6 +250,15 @@ export class SessionController extends TypertRemoteService {
     return buildModelCatalog(this.ctx)
   }
 
+  /**
+   * Report whether this deployment can hand a Session workspace path to a native desktop.
+   * @returns true when the matching open operation is available.
+   */
+  @Remote
+  canOpenWorkspacePath(): boolean {
+    return this.canOpenPath()
+  }
+
   /**
    * Open one path prepared by a Session-aware caller on the Host desktop.
    * @param request - path after best-effort Session workspace resolution.

+ 15 - 40
packages/api/session-controller/tests/client-apply.client.spec.ts

@@ -1,8 +1,8 @@
 import { Context } from '@deepseek-ai/cordis'
 import type { Fiber } from '@deepseek-ai/cordis'
 import type {
+  ConnectionGeneration,
   ConnectionHandle,
-  HostDescription,
 } from '@deepseek-ai/dsh-client-connection/client'
 import {
   RemoteStreamCarrierError,
@@ -16,13 +16,7 @@ import * as SessionClient from '../src/client/index.ts'
 import { ClientSessions } from '../src/client/sessions/service.ts'
 import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
 
-const DESCRIPTION: HostDescription = {
-  version: 'fixture',
-  cwd: '/fixture',
-  attachedSessions: 0,
-  home: '/home/fixture',
-  canOpenPath: true,
-}
+const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } }
 
 const sid = (value: string): SessionId => value as SessionId
 
@@ -34,7 +28,7 @@ interface Bench {
   readonly fiber: Fiber
   readonly sessions: ClientSessions
   dispatch(event: string, ...args: unknown[]): void
-  publishHost(description: HostDescription | undefined): void
+  publishGeneration(generation: ConnectionGeneration | undefined): void
 }
 
 const contexts = new Set<Context>()
@@ -45,41 +39,22 @@ afterEach(async () => {
   contexts.clear()
 })
 
-async function mount(initialHost?: HostDescription): Promise<Bench> {
+async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
   const ctx = new Context()
   contexts.add(ctx)
   await ctx.plugin(TypertRegistry)
   const api = new FakeApiClient()
   const remote = fakeRemote(api)
   const listeners = new Map<string, Set<RemoteListener>>()
-  const hostListeners = new Set<() => void>()
-  let host = initialHost
+  const generationListeners = new Set<() => void>()
+  let generation = initialGeneration
   const connection: ConnectionHandle = {
-    api,
     isLoopback: true,
-    hostDescription: {
-      getSnapshot: () => host,
-      subscribe: (listener) => {
-        hostListeners.add(listener)
-        return () => { hostListeners.delete(listener) }
-      },
-    },
-    generation: {
-      getSnapshot: () => host === undefined
-        ? undefined
-        : { id: 1, host: { home: host.home } },
-      subscribe: (listener) => {
-        hostListeners.add(listener)
-        return () => { hostListeners.delete(listener) }
-      },
-    },
     generation: {
-      getSnapshot: () => host === undefined
-        ? undefined
-        : { id: 1, host: { home: host.home } },
+      getSnapshot: () => generation,
       subscribe: (listener) => {
-        hostListeners.add(listener)
-        return () => { hostListeners.delete(listener) }
+        generationListeners.add(listener)
+        return () => { generationListeners.delete(listener) }
       },
     },
     rpc: {
@@ -115,9 +90,9 @@ async function mount(initialHost?: HostDescription): Promise<Bench> {
     dispatch: (event, ...args) => {
       for (const listener of listeners.get(event) ?? []) listener(...args as never[])
     },
-    publishHost: (description) => {
-      host = description
-      for (const listener of [...hostListeners]) listener()
+    publishGeneration: (next) => {
+      generation = next
+      for (const listener of [...generationListeners]) listener()
     },
   }
 }
@@ -166,7 +141,7 @@ describe('Session Controller Client apply', () => {
   it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => {
     const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
     const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
-    const bench = await mount(DESCRIPTION)
+    const bench = await mount(GENERATION)
     await flush()
 
     expect(accept).toHaveBeenCalledWith({
@@ -198,7 +173,7 @@ describe('Session Controller Client apply', () => {
   })
 
   it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => {
-    const bench = await mount(DESCRIPTION)
+    const bench = await mount(GENERATION)
     await flush()
     expect(bench.sessions.list.getSnapshot().phase).toBe('ready')
 
@@ -230,7 +205,7 @@ describe('Session Controller Client apply', () => {
     await flush()
     expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1)
 
-    bench.publishHost(DESCRIPTION)
+    bench.publishGeneration(GENERATION)
     await flush()
     expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2)
   })

+ 4 - 17
packages/api/session-controller/tests/fake-api.client.ts

@@ -1,8 +1,8 @@
-// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
+// Test-local programmable Remote fake (NOT the fixture: fixture is a demo
 // data source on a real clock; behavior tests need per-case responses and
 // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl.
 import type {
-  IApiClient, MessageId,
+  MessageId,
   RpcError, RpcResponse, SessionId, SessionSearchItem,
   SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt,
   WorkspaceId, WorkspaceView,
@@ -122,7 +122,7 @@ export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes {
   return api.sessionRemotes()
 }
 
-export class FakeApiClient implements IApiClient {
+export class FakeApiClient {
   /** Chronological call record: [method, payload]. */
   readonly calls: { method: string; payload: unknown }[] = []
   /** Session ids in physical follow-generation opening order. */
@@ -157,16 +157,6 @@ export class FakeApiClient implements IApiClient {
   onOpenWorkspacePath: (payload: unknown) => Promise<RemoteResult<{ opened: true }>> =
     () => Promise.resolve(remoteOk({ opened: true as const }))
 
-  onDescribe: (payload: unknown) => Promise<RpcResponse<{
-    version: string
-    cwd: string
-    attachedSessions: number
-    home: string
-    canOpenPath: boolean
-  }>> =
-    () => Promise.resolve(ok({
-      version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
-    }))
   private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
   private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
   private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
@@ -191,10 +181,6 @@ export class FakeApiClient implements IApiClient {
   onSubagentInterrupt: (payload: unknown) => Promise<RemoteResult<SubagentInterruptReceipt>>
     = () => Promise.resolve(remoteOk({ accepted: true as const }))
 
-  readonly host: IApiClient['host'] = {
-    describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
-  }
-
   onWorkspaceCreate: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
     () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true }))
 
@@ -223,6 +209,7 @@ export class FakeApiClient implements IApiClient {
         execute: () => Promise.resolve({ ok: true, value: undefined }),
       },
       session: {
+        canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)),
         list: payload => this.remoteResult('session.list', payload, this.onList(payload)),
         modelCatalog: () => Promise.resolve({
           ok: true,

+ 33 - 0
packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts

@@ -15,6 +15,39 @@ async function context(): Promise<Context> {
 }
 
 describe('session/openWorkspacePath', () => {
+  it('reports the deployment opener capability independently of a Session', async () => {
+    const ctx = await context()
+    const remote = createSessionTestRemote(ctx, {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/default',
+      canOpenPath: () => false,
+    })
+
+    await expect(remote.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false })
+  })
+
+  it('derives opener availability from config, an injected opener, or the platform probe', async () => {
+    const configured = createSessionTestRemote(await context(), {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/default',
+      nativeOpen: false,
+    })
+    await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false })
+
+    const injected = createSessionTestRemote(await context(), {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/default',
+      openPath: () => Promise.resolve(),
+    })
+    await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true })
+
+    const detected = createSessionTestRemote(await context(), {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/default',
+    })
+    await expect(detected.canOpenWorkspacePath()).resolves.toMatchObject({ ok: true })
+  })
+
   it('hands a Client-resolved workspace path to the Host opener unchanged', async () => {
     const ctx = await context()
     const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())

+ 14 - 4
packages/api/session-controller/tests/test-remote.ts

@@ -51,6 +51,7 @@ import type {
 
 /** Direct test face matching the generated `ctx.remote.session` unary methods. */
 export interface TestSessionRemote {
+  canOpenWorkspacePath(): Promise<RemoteResult<boolean>>
   list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
   search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
   create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
@@ -76,8 +77,10 @@ export interface TestSessionRemoteDefaults {
   readonly defaultModelSelection: () => AgentModelSelection
   readonly cwd: string
   readonly coldBlankProbeMaxBytes?: number
+  readonly nativeOpen?: boolean
   readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
   readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
+  readonly canOpenPath?: () => boolean
 }
 
 const installed = new WeakMap<Context, SessionController>()
@@ -185,10 +188,16 @@ function installControllers(
   try {
     controller = new SessionController(
       ctx,
-      defaults.coldBlankProbeMaxBytes === undefined
-        ? {}
-        : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes },
-      defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
+      {
+        ...defaults.coldBlankProbeMaxBytes === undefined
+          ? {}
+          : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes },
+        ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen },
+      },
+      {
+        ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
+        ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath },
+      },
     )
   } finally {
     cwd.mockRestore()
@@ -233,6 +242,7 @@ export function createSessionTestRemote(
 ): TestSessionRemote {
   const direct = createSessionTestController(ctx, defaults)
   return {
+    canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()),
     list: (request, signal = new AbortController().signal) => remoteResult(
       () => direct.list(request, signal),
       signal,

+ 9 - 0
packages/api/settings-controller/src/index.ts

@@ -128,6 +128,15 @@ export class SettingsController extends TypertRemoteService {
     }
   }
 
+  /**
+   * Report whether this deployment can open an authored Agent preset directory natively.
+   * @returns true when the matching open operation is available.
+   */
+  @Remote
+  canOpenAgentPresetDirectory(): boolean {
+    return this.canOpenPath()
+  }
+
   /**
    * Merge a patch into one namespace's stored user section.
    * @param ns - namespace key to write.

+ 3 - 0
packages/api/settings-controller/tests/settings-controller.host.spec.ts

@@ -82,6 +82,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
     expect(controller.typertRemote.namespace).toBe('settings')
     expect(remoteMethods(controller)).toEqual([
       { method: 'describe', invocation: { kind: 'direct' } },
+      { method: 'canOpenAgentPresetDirectory', invocation: { kind: 'direct' } },
       { method: 'update', invocation: { kind: 'direct' } },
       { method: 'replace', invocation: { kind: 'direct' } },
       { method: 'mutate', invocation: { kind: 'direct' } },
@@ -348,6 +349,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
     } as never)
     const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
     const openable = new SettingsController(ctx, { nativeOpen: true }, { openPath })
+    expect(openable.canOpenAgentPresetDirectory()).toBe(true)
     const signal = new AbortController().signal
     await expect(openable.openAgentPresetDirectory('mine', signal))
       .resolves.toEqual({ opened: true })
@@ -360,6 +362,7 @@ describe('the settings Remote namespace a configuration page calls', () => {
       }),
     } as never)
     const reveal = new SettingsController(headless, { nativeOpen: false })
+    expect(reveal.canOpenAgentPresetDirectory()).toBe(false)
     await expect(reveal.openAgentPresetDirectory('mine', new AbortController().signal))
       .resolves.toEqual({ opened: false, path: '/presets/mine' })
   })

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

@@ -197,18 +197,7 @@ async function waitFor(check: () => void): Promise<void> {
 
 function provideClientServices(ctx: Context, remote: WorkspaceRemote): void {
   const connection: ConnectionHandle = {
-    api: {} as ConnectionHandle['api'],
     isLoopback: true,
-    hostDescription: {
-      getSnapshot: () => ({
-        version: 'fixture',
-        cwd: '/fixture',
-        attachedSessions: 0,
-        home: '/home/fixture',
-        canOpenPath: true,
-      }),
-      subscribe: () => () => {},
-    },
     generation: AVAILABLE_CONNECTION.generation,
     rpc: {
       call: () => Promise.reject(new Error('unexpected generic RPC call')),

+ 2 - 4
packages/client/ui-agent-preset/src/client/index.ts

@@ -11,7 +11,6 @@
  * before-the-fact, while the header only reports what a session already runs.
  */
 
-import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
 // Type-only: pulls the Session Controller service merge (ctx.sessions).
 import type {} from '@deepseek-ai/dsh-api-session-controller/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -51,7 +50,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t
 
 /** Required services (cordis fiber inject). */
 export const inject = [
-  'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
+  'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
 ]
 
 /**
@@ -59,13 +58,12 @@ export const inject = [
  * @param ctx - the browser plugin context.
  */
 export function apply(ctx: ClientContext): void {
-  const { api } = ctx.get('connection') as ConnectionHandle
   const settingsWire = { settings: ctx.remote.settings }
   const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe())
   // One roster, four surfaces. The chip is registered in a later scope, so it
   // subscribes here rather than being reached from this one.
   const rosterReaders = new Set<() => void>()
-  const section = new AgentPresetSectionController(api, ctx.remote, () => {
+  const section = new AgentPresetSectionController(ctx.remote, () => {
     void controller.load()
     for (const read of rosterReaders) read()
   })

+ 3 - 4
packages/client/ui-agent-preset/src/client/section-store.ts

@@ -14,7 +14,7 @@
  * more than the row it targeted.
  */
 
-import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts'
 
@@ -133,7 +133,6 @@ export class AgentPresetSectionController {
   readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
 
   constructor(
-    private readonly api: Pick<IApiClient, 'host'>,
     private readonly remote: Pick<ClientRemote, 'agentPresets' | 'settings'>,
     /**
      * Called after this page changes the roster DIRECTORY, so the other
@@ -168,13 +167,13 @@ export class AgentPresetSectionController {
     // Issued together: one round trip decides the page, and a load that waited
     // for them in turn would hold the section in `loading` twice as long,
     // where a concurrent reload silently returns instead of refreshing.
-    const opener = this.api.host.describe({})
+    const opener = this.remote.settings.canOpenAgentPresetDirectory()
     const roster = await beginRosterRead(this.remote, this.store)
     // A refused describe leaves the reveal-the-path path, which needs no opener.
     const described = await opener.catch(() => undefined)
     if (roster === undefined) return
     const { presets, authorable } = roster
-    const hasDocument = described?.result.ok === true && described.result.value.canOpenPath
+    const hasDocument = described?.ok === true && described.value
     if (presets.length === 0) {
       // Nothing to manage leaves nothing to keep a dialog open over.
       this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null })

+ 3 - 11
packages/client/ui-agent-preset/tests/apply.client.spec.ts

@@ -74,6 +74,7 @@ async function bench() {
   // The row reads `describe` to learn whether this browser may write at all,
   // and its default write is the one op this spec records.
   const settings = {
+    canOpenAgentPresetDirectory: () => Promise.resolve({ ok: true as const, value: true }),
     describe: () => Promise.resolve({
       ok: true as const,
       value: { writable: true, hasDocument: true, namespaces: [] },
@@ -114,16 +115,7 @@ async function bench() {
   }
   ctx.provide('remote.agentPresets', agentPresets as never)
   Object.assign(remote, { agentPresets })
-  ctx.provide('connection', {
-    api: {
-      host: {
-        describe: () => Promise.resolve({
-          rpcId: 'r',
-          result: { ok: true as const, value: { canOpenPath: true } },
-        }),
-      },
-    },
-  } as never)
+  ctx.provide('connection', { isLoopback: true } as never)
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
   return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote }
 }
@@ -185,7 +177,7 @@ function sessionsDouble(state: {
 describe('ui-agent-preset apply', () => {
   it('declares the services it uses', () => {
     expect(inject).toEqual([
-      'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
+      'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope',
     ])
   })
 

+ 12 - 28
packages/client/ui-agent-preset/tests/section-store.client.spec.ts

@@ -7,7 +7,7 @@
  */
 
 import { describe, expect, it } from 'vitest'
-import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
 import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
 import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
 
@@ -41,36 +41,16 @@ interface FakeOptions {
   authorable?: boolean
   /** Whether the host can open a preset directory on a desktop. */
   hasDocument?: boolean
-  /** Reject `host.describe`, as a dead transport does. */
-  throwDescribe?: boolean
+  /** Reject the opener capability read, as a dead transport does. */
+  throwCapability?: boolean
   /** Hold `remove` until this resolves, to observe the in-flight state. */
   holdRemove?: Promise<void>
 }
 
-const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } })
 const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value })
 const remoteFail = (message: string) =>
   Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } })
 
-/**
- * The carried wire face: the desktop opener, the default write, and the opener
- * capability the page joins onto the roster.
- * @param defaultId - the preset a session with no choice gets.
- * @param options - failure injection and call recording.
- * @returns the fake client.
- */
-function fakeApi(
-  options: FakeOptions = {},
-): Pick<IApiClient, 'host'> {
-  return {
-    host: {
-      describe: () => (options.throwDescribe === true
-        ? Promise.reject(new Error('socket closed'))
-        : ok({ canOpenPath: options.hasDocument ?? true })),
-    },
-  } as Pick<IApiClient, 'host'>
-}
-
 /**
  * The Remote namespace over an in-memory preset store: copies land, so the
  * roster the controller re-reads after a copy is the one the copy produced.
@@ -143,6 +123,12 @@ function fakeRemote(
       },
     },
     settings: {
+      canOpenAgentPresetDirectory: () => {
+        record('canOpenAgentPresetDirectory', {})
+        return options.throwCapability === true
+          ? Promise.reject(new Error('socket closed'))
+          : remoteOk(options.hasDocument ?? true)
+      },
       update: (ns: string, patch: { default?: string }) => {
         record('settings.update', { ns, patch })
         if (options.failSettings !== undefined) return remoteFail(options.failSettings)
@@ -176,7 +162,6 @@ function harness(options: FakeOptions = {}) {
   let rosterChanges = 0
   const wired = { ...options, calls: options.calls ?? calls }
   const controller = new AgentPresetSectionController(
-    fakeApi(wired),
     fakeRemote(presets, defaultId, wired),
     () => { rosterChanges += 1 },
   )
@@ -191,11 +176,11 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft {
 
 describe('loading the roster', () => {
   it('still lists the roster when the opener capability cannot be read', async () => {
-    const { controller } = harness({ throwDescribe: true })
+    const { controller } = harness({ throwCapability: true })
 
     await controller.load()
 
-    // The two reads are independent: a refused `host.describe` costs the
+    // The two reads are independent: a refused capability query costs the
     // open-directory affordance, not the page.
     const state = controller.store.getSnapshot()
     expect(state.status).toBe('ready')
@@ -572,7 +557,6 @@ describe('deleting', () => {
     await controller.load()
     presets.clear()
     const broken = new AgentPresetSectionController(
-      { host: {} } as unknown as Pick<IApiClient, 'host'>,
       {
         agentPresets: {
           list: () => Promise.reject(new Error('gone')),
@@ -596,7 +580,7 @@ describe('a controller with no roster listener', () => {
     const presets = seed()
     const defaultId = { id: 'standard' }
     const alone = new AgentPresetSectionController(
-      fakeApi(), fakeRemote(presets, defaultId))
+      fakeRemote(presets, defaultId))
     await alone.load()
     alone.confirmDelete('mine')
 

+ 4 - 0
packages/client/ui-deliverables/package.json

@@ -32,6 +32,7 @@
   "dsh": {
     "client": {
       "inject": [
+        "@deepseek-ai/dsh-api-remotes",
         "@deepseek-ai/dsh-client-connection",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-chat",
@@ -47,6 +48,7 @@
   },
   "license": "MIT",
   "peerDependencies": {
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
@@ -58,8 +60,10 @@
     "@deepseek-ai/dsh-session": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",

+ 9 - 7
packages/client/ui-deliverables/src/client/ProducedFiles.tsx

@@ -1,6 +1,5 @@
-import { useLayoutEffect, useRef, useState } from 'react'
-import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
-import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { basename } from './turn-deliverables.ts'
 import type { NS } from './locales.ts'
@@ -44,9 +43,11 @@ export function fitProducedFiles(
 export interface ProducedFilesInjected {
   /** Whether the browser itself is connected over loopback. */
   isLoopback: boolean
+  /** Load the opener capability when this row first reaches the page. */
+  ensureWorkspacePathOpen(): void
   hooks: {
-    /** Current generation's Host description, bound by the slot renderer. */
-    hostDescription: HostDescriptionSource
+    /** Current generation's Session workspace opener capability. */
+    workspacePathOpen: HostObservable<boolean | undefined>
   }
 }
 
@@ -65,9 +66,10 @@ function moreLabel(t: ProducedFilesProps['t'], count: number): string {
  * @returns The produced-files row.
  */
 export function ProducedFiles({
-  matched: paths, openFile, isLoopback, useHostDescription, t,
+  matched: paths, openFile, isLoopback, ensureWorkspacePathOpen, useWorkspacePathOpen, t,
 }: ProducedFilesProps) {
-  const hostCanOpenPath = useHostDescription(description => description?.canOpenPath === true)
+  useEffect(() => { ensureWorkspacePathOpen() }, [ensureWorkspacePathOpen])
+  const hostCanOpenPath = useWorkspacePathOpen(available => available === true)
   const canOpenPath = isLoopback && hostCanOpenPath
   const limit = Math.min(paths.length, SHOWN_LIMIT)
   const [shownCount, setShownCount] = useState(limit)

+ 33 - 2
packages/client/ui-deliverables/src/client/index.ts

@@ -9,6 +9,8 @@
  */
 import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-api-remotes/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -30,7 +32,7 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
 export { producedForClosing } from './turn-deliverables.ts'
 
 /** Required services for the tail-slot registration and its dictionaries. */
-export const inject = ['slots', 'locale', 'uiConversation', 'connection']
+export const inject = ['slots', 'locale', 'uiConversation', 'connection', 'remote', 'remote.session']
 
 /**
  * Client plugin body: register the dictionaries and the turn-tail entry.
@@ -38,6 +40,34 @@ export const inject = ['slots', 'locale', 'uiConversation', 'connection']
  */
 export function apply(ctx: ClientContext): void {
   const connection = ctx.get('connection') as ConnectionHandle
+  const workspacePathOpen = createSnapshotStore<boolean | undefined>(undefined)
+  let requestedWorkspacePathOpen = false
+  let capabilityRevision = 0
+  let pendingCapability: Promise<void> | undefined
+  const loadWorkspacePathOpen = (): void => {
+    if (pendingCapability !== undefined) return
+    const revision = capabilityRevision
+    const pending = ctx.remote.session.canOpenWorkspacePath()
+      .then((result) => {
+        if (revision === capabilityRevision) workspacePathOpen.set(result.ok && result.value)
+      }, () => {
+        if (revision === capabilityRevision) workspacePathOpen.set(false)
+      })
+      .finally(() => {
+        if (pendingCapability === pending) pendingCapability = undefined
+      })
+    pendingCapability = pending
+  }
+  const ensureWorkspacePathOpen = (): void => {
+    requestedWorkspacePathOpen = true
+    if (workspacePathOpen.getSnapshot() === undefined) loadWorkspacePathOpen()
+  }
+  ctx.on('connection/reset', () => {
+    capabilityRevision++
+    pendingCapability = undefined
+    workspacePathOpen.set(undefined)
+    if (requestedWorkspacePathOpen) loadWorkspacePathOpen()
+  })
   ctx.uiConversation.events.register(deliverablesDefinition)
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
   ctx.slots.inject(
@@ -48,7 +78,8 @@ export function apply(ctx: ClientContext): void {
       locale: NS,
       inject: () => ({
         isLoopback: connection.isLoopback,
-        hooks: { hostDescription: connection.hostDescription },
+        ensureWorkspacePathOpen,
+        hooks: { workspacePathOpen },
       }),
     }, ProducedFiles),
   )

+ 69 - 11
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -22,7 +22,7 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import {
-  fitProducedFiles, ProducedFiles, type ProducedFilesProps,
+  fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps,
 } from '../src/client/ProducedFiles.tsx'
 import {
   basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
@@ -403,13 +403,11 @@ describe('ProducedFiles row', () => {
   const capability = (
     canOpenPath: boolean | undefined,
     isLoopback = true,
-  ): Pick<ProducedFilesProps, 'isLoopback' | 'useHostDescription'> => {
-    const description = canOpenPath === undefined
-      ? undefined
-      : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath }
+  ): Pick<ProducedFilesProps, 'isLoopback' | 'ensureWorkspacePathOpen' | 'useWorkspacePathOpen'> => {
     return {
       isLoopback,
-      useHostDescription: selector => selector(description),
+      ensureWorkspacePathOpen: () => {},
+      useWorkspacePathOpen: selector => selector(canOpenPath),
     }
   }
 
@@ -580,14 +578,17 @@ describe('plugin registration', () => {
       name: 'root',
       children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
     } as never, () => null)
-    const hostDescription = { getSnapshot: () => undefined, subscribe: () => () => {} }
+    const generation = { getSnapshot: () => undefined, subscribe: () => () => {} }
     ctx.provide('connection', {
-      api: { settings: {} },
       isLoopback: false,
-      hostDescription,
+      generation,
     } as never)
     // ui-theme's Appearance row binds a durable scope through these two.
-    ctx.provide('remote', { $on: () => () => {} } as never)
+    const session = {
+      canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }),
+    }
+    ctx.provide('remote', { $on: () => () => {}, session } as never)
+    ctx.provide('remote.session', session as never)
     ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
 
@@ -595,7 +596,16 @@ describe('plugin registration', () => {
     await fiber.await()
     const [entry] = ctx.slots.entries('conversation.chat.turnTail')
     expect(entry).toBeDefined()
-    expect(entry?.inject?.()).toEqual({ isLoopback: false, hooks: { hostDescription } })
+    const injected = entry?.inject?.() as unknown as ProducedFilesInjected
+    expect(injected.isLoopback).toBe(false)
+    expect(typeof injected.ensureWorkspacePathOpen).toBe('function')
+    expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined()
+    ctx.emit('connection/reset')
+    injected.ensureWorkspacePathOpen()
+    await vi.waitFor(() => {
+      expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true)
+    })
+    injected.ensureWorkspacePathOpen()
 
     // The prose face is live while the plugin is: a produced turn yields a
     // resolver whose matches open through the owner-supplied opener.
@@ -617,4 +627,52 @@ describe('plugin registration', () => {
     // Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
     expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
   })
+
+  it('queries the workspace opener lazily and replaces stale results after reconnect', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SlotRegistry).await()
+    new UiConversation(ctx, { binding: () => undefined } as never)
+    ctx.slots.register({
+      name: 'root',
+      children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
+    } as never, () => null)
+    ctx.provide('connection', {
+      isLoopback: true,
+      generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
+    } as never)
+    const first = Promise.withResolvers<{ ok: true; value: boolean }>()
+    const second = Promise.withResolvers<{ ok: true; value: boolean }>()
+    const staleFailure = Promise.withResolvers<{ ok: true; value: boolean }>()
+    const capability = vi.fn()
+      .mockReturnValueOnce(first.promise)
+      .mockReturnValueOnce(second.promise)
+      .mockReturnValueOnce(staleFailure.promise)
+      .mockRejectedValueOnce(new Error('offline'))
+    const session = { canOpenWorkspacePath: capability }
+    ctx.provide('remote', { $on: () => () => {}, session } as never)
+    ctx.provide('remote.session', session as never)
+    ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
+    await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
+    const fiber = ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    const entry = ctx.slots.entries('conversation.chat.turnTail')[0]
+    const injected = entry?.inject?.() as unknown as ProducedFilesInjected
+
+    injected.ensureWorkspacePathOpen()
+    injected.ensureWorkspacePathOpen()
+    expect(capability).toHaveBeenCalledOnce()
+    ctx.emit('connection/reset')
+    expect(capability).toHaveBeenCalledTimes(2)
+    first.resolve({ ok: true, value: false })
+    await Promise.resolve()
+    expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined()
+    second.resolve({ ok: true, value: true })
+    await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) })
+
+    ctx.emit('connection/reset')
+    ctx.emit('connection/reset')
+    staleFailure.reject(new Error('stale offline'))
+    await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) })
+    await fiber.dispose()
+  })
 })

+ 6 - 0
packages/client/ui-deliverables/tsconfig.json

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
     {
       "path": "../../../vendor/cordis"
     },
@@ -17,6 +20,9 @@
     {
       "path": "../locale"
     },
+    {
+      "path": "../store"
+    },
     {
       "path": "../ui-conversation"
     },

+ 1 - 1
packages/client/ui-reference/src/client/index.ts

@@ -64,7 +64,7 @@ export function apply(ctx: ClientContext): void {
       // when there is no header to carry it.
       const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined
       const now = Date.now()
-      const home = connection.hostDescription.getSnapshot()?.home
+      const home = connection.generation.getSnapshot()?.host.home
       const listed = sessions.list.getSnapshot().byId
       return [
         ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)),

+ 2 - 2
packages/client/ui-reference/tests/browser-plugin.client.spec.ts

@@ -92,7 +92,7 @@ async function bench(
   ctx.provide('remote.fileReferences', { list: files })
   ctx.provide('remote.sessionReferenceResolver', { candidates: sessions })
   ctx.provide('locale', new LocaleRuntime(ctx))
-  ctx.provide('connection', { hostDescription: { getSnapshot: () => ({ home: HOME }) } })
+  ctx.provide('connection', { generation: { getSnapshot: () => ({ id: 1, host: { home: HOME } }) } })
   ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } })
   const fiber = ctx.plugin({ inject: [...inject], apply })
   await fiber.await()
@@ -124,7 +124,7 @@ describe('apply', () => {
     ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) })
     ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) })
     ctx.provide('locale', new LocaleRuntime(ctx))
-    ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined } })
+    ctx.provide('connection', { generation: { getSnapshot: () => undefined } })
     ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } })
     const ownFiber = ctx.plugin({ inject: [...inject], apply })
     await ownFiber.await()

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

@@ -24,7 +24,7 @@ export const inject = ['slots', 'connection']
  */
 export function apply(ctx: ClientContext): void {
   const connection = ctx.get('connection') as ConnectionHandle
-  const toolInject = () => ({ hooks: { hostDescription: connection.hostDescription } })
+  const toolInject = () => ({ hooks: { connectionGeneration: connection.generation } })
   ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
     name: 'conversation.chat.node',
     key: 'tool-call',

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

@@ -1,5 +1,5 @@
 /** Tool UI slot declarations and their composed component props. */
-import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
+import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client'
 import type { 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'
@@ -47,10 +47,10 @@ export interface ToolCallOwnerProps {
 export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
 
 /** Injected Host description for POSIX home-path display. */
-export type ToolHostDescriptionInjected = {
+export type ToolConnectionGenerationInjected = {
   hooks: {
-    /** Current generation's Host description, bound by the slot renderer. */
-    hostDescription: HostDescriptionSource
+    /** Current Connection generation, bound by the slot renderer. */
+    connectionGeneration: ConnectionGenerationState
   }
 }
 
@@ -58,9 +58,9 @@ export type ToolHostDescriptionInjected = {
 export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'>
   & PropsRenderSlots<'tool.call.toolview'>
   & PropsLocale<'conversation'>
-  & InjectFace<ToolHostDescriptionInjected>
+  & InjectFace<ToolConnectionGenerationInjected>
 
 /** Full props of the selected Tool output renderer in the details panel. */
 export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'>
   & PropsLocale<'conversation'>
-  & InjectFace<ToolHostDescriptionInjected>
+  & InjectFace<ToolConnectionGenerationInjected>

+ 1 - 1
packages/client/ui-tool/src/client/index.ts

@@ -1,5 +1,5 @@
 /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */
 export { apply, inject } from './apply.ts'
 export type {
-  ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps,
+  ToolCallOwnerProps, ToolCallViewProps, ToolConnectionGenerationInjected, ToolDetailsProps, ToolTreeProps,
 } from './contract/slots.ts'

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

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

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

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

+ 1 - 1
packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx

@@ -140,7 +140,7 @@ type AskQuestionRowProps = ToolCallViewProps & PropsLocale<'conversation'>
 export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) {
   const model = toolRowModel(toolName, block)
   // Composer verdicts settle the call as specific UserQuestionErrors
-  // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
+  // (ask_user_question handler): 'ASK_CANCELLED' is the user's own
   // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
   // question was pending. Both name their verdict instead of the generic
   // failed shape, and the abort keeps the shared stopped (amber) semantics of

+ 2 - 2
packages/client/ui-tool/tests/ask-question-row.client.spec.tsx

@@ -168,7 +168,7 @@ describe('AskQuestionRow', () => {
   })
 
   it('user cancellation shows the original questions without raw JSON or an error body', () => {
-    // ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
+    // ASK_CANCELLED: the ask_user_question handler's cancel error.
     const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
       { isError: true, error: { name: 'UserQuestionError', code: 'ASK_CANCELLED' } }))} />)
     expect(screen.getByText('已取消')).toBeTruthy()
@@ -184,7 +184,7 @@ describe('AskQuestionRow', () => {
   })
 
   it('a turn abort shows the original questions with stopped semantics', () => {
-    // ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
+    // ASK_ABORTED: the ask handler's turn-abort settlement.
     const view = render(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
       { isError: true, error: { name: 'UserQuestionError', code: 'ASK_ABORTED' } }))} />)
     expect(screen.getByText('已中断')).toBeTruthy()

+ 1 - 2
packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx

@@ -72,9 +72,8 @@ const LAYOUT_CHILDREN = {
 async function bench(nodes: ToolResultNode[]) {
   const runtime = await SlotTestRuntime.create()
   runtime.ctx.provide('connection', {
-    api: { settings: {} },
     isLoopback: false,
-    hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
+    generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
   })
   new TestRemote(runtime.ctx, {
     session: {

+ 1 - 2
packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx

@@ -121,9 +121,8 @@ async function bench(snapshot: ChatSnapshot) {
   ctx.provide('uiWorkspace', {} as never)
   new TestRemote(ctx, { session: { openWorkspacePath } })
   ctx.provide('connection', {
-    api: { settings: {} },
     isLoopback: false,
-    hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
+    generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
   } as never)
   const locale = new LocaleRuntime(ctx)
   ctx.provide('locale', locale)

+ 1 - 3
packages/client/ui-tool/tests/read-card.client.spec.tsx

@@ -361,9 +361,7 @@ describe('DetailsPanel Output section (read)', () => {
   it('abbreviates a leftover POSIX home path on the read card label', () => {
     const view = mount(snapshot({
       nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })],
-    }), target, '/tmp/ws', {
-      version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false,
-    })
+    }), target, '/tmp/ws', { id: 1, host: { home: '/Users/u' } })
     expect(view.getByText('~/notes.md')).toBeTruthy()
   })
 

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

@@ -2,7 +2,7 @@
 /** ToolCallTree-owned root/subcall markers and selection projection. */
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
-import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
+import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client'
 import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -23,7 +23,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => (
 function props(
   block: ToolResultNode,
   selectedCallId?: string,
-  description?: HostDescription,
+  generation?: ConnectionGeneration,
   owners?: ToolCallOwnerProps[],
 ): ToolTreeProps {
   const snapshot = {} as SessionSnapshot
@@ -50,7 +50,7 @@ function props(
     inspectCall: vi.fn(),
     forkAt: vi.fn(),
     fileMentions: vi.fn(),
-    useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'],
+    useConnectionGeneration: (selector => selector(generation)) as ToolTreeProps['useConnectionGeneration'],
     t,
   } as unknown as ToolTreeProps
 }
@@ -98,9 +98,7 @@ describe('ToolCallTree', () => {
 
   it('abbreviates a POSIX home path in the generic tool summary', () => {
     const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' })
-    const view = render(<ToolCallTree {...props(block, 'w1', {
-      version: '0', cwd: '/tmp', attachedSessions: 0, home: '/h', canOpenPath: false,
-    })} />)
+    const view = render(<ToolCallTree {...props(block, 'w1', { id: 1, host: { home: '/h' } })} />)
     expect(view.getByText('~/docs/a.ts')).toBeTruthy()
   })
 })

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

@@ -1,5 +1,5 @@
 /** Test adapter for the production conversation.details.tool registration. */
-import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
+import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client'
 import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
 import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
 import type {
@@ -144,12 +144,12 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
 /**
  * Bind ui-tool's details renderer to the conversation slot callback shape.
  * @param t - conversation locale seat used by Tool cards.
- * @param description - optional Host description so the details card can abbreviate home paths.
+ * @param generation - optional Connection generation carrying the Host home.
  * @returns a direct-test renderSlot implementation.
  */
 export function renderToolDetails(
   t: TranslateNS<'conversation'>,
-  description?: HostDescription,
+  generation?: ConnectionGeneration,
 ): DetailsSlotProps['renderSlot'] {
   return (_key, owner) => {
     // PropsRenderSlots keeps its key generic even for this one-key share;
@@ -158,7 +158,7 @@ export function renderToolDetails(
     return <ToolDetails
       block={details.block}
       cwd={details.cwd}
-      useHostDescription={selector => selector(description)}
+      useConnectionGeneration={selector => selector(generation)}
       t={t}
     />
   }

+ 2 - 4
packages/client/ui-tool/tests/toolview-slot.client.spec.tsx

@@ -60,9 +60,8 @@ const LAYOUT_CHILDREN = {
 async function bench(nodes: ToolResultNode[]) {
   const runtime = await SlotTestRuntime.create()
   runtime.ctx.provide('connection', {
-    api: { settings: {} },
     isLoopback: false,
-    hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
+    generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
   })
   const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } }))
   new TestRemote(runtime.ctx, { session: { openWorkspacePath } })
@@ -207,9 +206,8 @@ describe('registrant declaration injection', () => {
   it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => {
     const runtime = await SlotTestRuntime.create()
     runtime.ctx.provide('connection', {
-      api: { settings: {} },
       isLoopback: false,
-      hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
+      generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
     })
     new TestRemote(runtime.ctx, {
       session: {

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

@@ -22,7 +22,7 @@
  * and a hole has exactly one declaring entry — they carry the same owner
  * contract and the same occupant.
  */
-import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
+import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client'
 import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: pull the owner SlotMap merges into programs that resolve the
 // runtime shares below.
@@ -90,7 +90,7 @@ export type DirectoryPickingHooks = PropsHooks<DirectoryPickingInjected['hooks']
 export type WorkspaceBrowserInjected = {
   hooks: DirectoryPickingInjected['hooks'] & {
     /** Current generation's Host description, bound by the slot renderer. */
-    hostDescription: HostDescriptionSource
+    connectionGeneration: ConnectionGenerationState
   }
   /**
    * Start a New Session in a Workspace: reuse-or-create its blank session and

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

@@ -73,7 +73,7 @@ export function apply(ctx: Context): void {
   const connection = ctx.get('connection') as ConnectionHandle
   const sessions = ctx.get('sessions') as ISessions
   const workspaces = ctx.get('workspaces') as IWorkspaces
-  const hostDescription = connection.hostDescription
+  const connectionGeneration = connection.generation
   const uiWorkspace = new UiWorkspaceService(
     ctx, ctx.remote.directoryPicker, workspaces, sessions)
   ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } })
@@ -125,7 +125,7 @@ export function apply(ctx: Context): void {
       await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
     },
     createWorkspace: input => workspaces.create(input),
-    hooks: { directoryFlow: browserFlowSource, hostDescription },
+    hooks: { directoryFlow: browserFlowSource, connectionGeneration },
   })
   const pickerInjected = (): WorkspacePickerInjected => ({
     createWorkspace: input => workspaces.create(input),

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

@@ -820,11 +820,11 @@ export function WorkspaceBrowser({
   searchSessions,
   searchResultLimit,
   useDirectoryFlow,
-  useHostDescription,
+  useConnectionGeneration,
   renderSlot,
   t,
 }: WorkspaceBrowserProps) {
-  const home = useHostDescription(description => description?.home)
+  const home = useConnectionGeneration(generation => generation?.host.home)
   const workspaces = useWorkspaces(state => state.items)
   const workspacePhase = useWorkspaces(state => state.phase)
   const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)

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

@@ -59,7 +59,7 @@ async function bench() {
     fork,
   } as never)
   ctx.provide('connection', {
-    hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
+    generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
   } as never)
   const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' }))
   const directoryPicker = { pick: pickDirectory }
@@ -162,7 +162,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.hooks.hostDescription.getSnapshot()).toBeUndefined()
+    expect(browser.hooks.connectionGeneration.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()

+ 1 - 1
packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx

@@ -35,7 +35,7 @@ async function createRuntime(): Promise<SlotTestRuntime> {
   const runtime = await SlotTestRuntime.create()
   runtime.releaseWorkspaceSource()
   runtime.ctx.provide('connection', {
-    hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
+    generation: { getSnapshot: () => undefined, subscribe: () => () => {} },
   })
   // The rename flow never picks a directory; the namespace only has to be there
   // for ui-workspace's inject to settle.

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

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