imccyu 1 месяц назад
Родитель
Сommit
378141bf5d

+ 6 - 0
knip.json

@@ -91,6 +91,12 @@
         "tests/**/*.ts"
       ]
     },
+    "packages/host/directory-picker-auto": {
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-ui-directory-picker",
+        "@deepseek-ai/dsh-client-ui-directory-picker-native"
+      ]
+    },
     "packages/host/directory-picker-native": {
       "entry": [
         "tests/**/*.spec.{ts,tsx}",

+ 5 - 13
packages/client/ui-goal/src/client/index.ts

@@ -8,7 +8,6 @@
  * their CAS ref reads the session's current projected value at call time.
  * Goal creation stays on the /goal host command.
  */
-import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
 import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -41,13 +40,6 @@ const NS = 'goal'
 /** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */
 export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents']
 
-/** Narrow one Remote mutation's result to the fields the goal strip renders. */
-function settle(result: RemoteResult<unknown>): GoalActionResult {
-  return result.ok
-    ? { ok: true }
-    : { ok: false, error: { code: result.error.code, message: result.error.message } }
-}
-
 /**
  * Client plugin body: the GoalBar dock entry with its mutation verbs.
  * @param ctx - client root context.
@@ -74,7 +66,7 @@ export function apply(ctx: ClientContext): void {
 
   const noCurrentGoal: GoalActionResult = {
     ok: false,
-    error: { code: 'no-current-goal', message: 'no current goal to mutate' },
+    error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} },
   }
 
   ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
@@ -86,22 +78,22 @@ export function apply(ctx: ClientContext): void {
       onEdit: async (objective) => {
         const ref = refOf(sessionId)
         if (ref === undefined) return noCurrentGoal
-        return settle(await ctx.remote.goals.edit(sessionId, ref, { objective }))
+        return await ctx.remote.goals.edit(sessionId, ref, { objective })
       },
       onPause: async () => {
         const ref = refOf(sessionId)
         if (ref === undefined) return noCurrentGoal
-        return settle(await ctx.remote.goals.pause(sessionId, ref))
+        return await ctx.remote.goals.pause(sessionId, ref)
       },
       onResume: async () => {
         const ref = refOf(sessionId)
         if (ref === undefined) return noCurrentGoal
-        return settle(await ctx.remote.goals.resume(sessionId, ref))
+        return await ctx.remote.goals.resume(sessionId, ref)
       },
       onClear: async () => {
         const ref = refOf(sessionId)
         if (ref === undefined) return noCurrentGoal
-        return settle(await ctx.remote.goals.clear(sessionId, ref))
+        return await ctx.remote.goals.clear(sessionId, ref)
       },
     }),
   }, GoalDock))

+ 8 - 4
packages/client/ui-goal/src/client/slots.ts

@@ -7,10 +7,14 @@
  * (callbacks from inject, live state from useProjection).
  */
 
-/** Settled outcome of one goal mutation, rendered inline by the strip. */
-export type GoalActionResult =
-  | { ok: true }
-  | { ok: false; error: { code: string; message: string } }
+import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
+
+/**
+ * Settled outcome of one goal mutation, rendered inline by the strip. The
+ * strip renders the failure only — the mutated goal arrives through the
+ * projection — so the success value stays unread here.
+ */
+export type GoalActionResult = RemoteResult<unknown>
 
 /** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */
 export interface GoalBarActions {

+ 28 - 39
packages/client/ui-goal/tests/browser-plugin.spec.tsx

@@ -5,8 +5,8 @@
  * conversation.input.dock, the inject face's four verbs read the CAS ref
  * from the session's CURRENT projected value at call time (no fence — the
  * Remote method's compare-and-set is the guard), a missing projection short-circuits
- * to the no-current-goal error without touching the wire, and Remote errors
- * map onto the inline-render result shape. Registration disposal rides the
+ * to the no-current-goal error without touching the wire, and a Remote failure
+ * reaches the strip verbatim. Registration disposal rides the
  * plugin fiber (HMR safety). The node half and the invariant companion are
  * exercised over the same Context.
  */
@@ -48,8 +48,7 @@ function makeProjection(revision = 3): GoalProjection {
 /** Boot the plugin over fake faces; Goal Remote methods record arguments and answer per the script. */
 async function bench(options: {
   projection?: GoalProjection | null | undefined
-  failWith?: { code: string; message: string }
-  rejectWith?: unknown
+  failWith?: { code: string; message: string; details: object }
 } = {}) {
   const ctx = new Context()
   const calls: { method: string; args: unknown[] }[] = []
@@ -57,12 +56,8 @@ async function bench(options: {
   function answer<T>(method: string, value: T) {
     return (...args: unknown[]) => {
       calls.push({ method, args })
-      // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the defensive scenario under test.
-      if ('rejectWith' in options) return Promise.reject(options.rejectWith)
-      if (options.failWith !== undefined) {
-        return Promise.reject(new Error(`Remote ${method} failed`, { cause: options.failWith }))
-      }
-      return Promise.resolve(value)
+      if (options.failWith !== undefined) return Promise.resolve({ ok: false, error: options.failWith })
+      return Promise.resolve({ ok: true, value })
     }
   }
   const ref = { id: 'g-1', revision: 3 }
@@ -139,10 +134,13 @@ describe('ui-goal browser plugin', () => {
     const b = await bench({ projection: makeProjection(5) })
     await b.fiber.await()
     const verbs = b.entry()!.inject!(sid('s1'))
-    expect(await verbs.onEdit('New objective')).toEqual({ ok: true })
-    expect(await verbs.onPause()).toEqual({ ok: true })
-    expect(await verbs.onResume()).toEqual({ ok: true })
-    expect(await verbs.onClear()).toEqual({ ok: true })
+    // The strip forwards the Remote value verbatim; `answered` is the fake's
+    // reply, unrelated to the CAS ref the call carries.
+    const answered = { id: 'g-1', revision: 3 }
+    expect(await verbs.onEdit('New objective')).toEqual({ ok: true, value: { ref: answered } })
+    expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: answered } })
+    expect(await verbs.onResume()).toEqual({ ok: true, value: { ref: answered } })
+    expect(await verbs.onClear()).toEqual({ ok: true, value: answered })
     expect(b.calls.map(c => c.method)).toEqual(['goals/edit', 'goals/pause', 'goals/resume', 'goals/clear'])
     const ref = { id: 'g-1', revision: 5 }
     expect(b.calls[0]?.args).toEqual(['s1', ref, { objective: 'New objective' }])
@@ -157,18 +155,22 @@ describe('ui-goal browser plugin', () => {
     const verbs = b.entry()!.inject!(sid('s1'))
     b.remountGoals()
 
-    expect(await verbs.onPause()).toEqual({ ok: true })
+    expect(await verbs.onPause()).toEqual({ ok: true, value: { ref: { id: 'g-1', revision: 3 } } })
     expect(b.calls).toMatchObject([{ method: 'remounted-goals/pause' }])
   })
 
-  it('settles every verb when the Remote namespace is temporarily absent', async () => {
+  it('rejects every verb once the Remote namespace is gone', async () => {
     const b = await bench({ projection: makeProjection() })
     await b.fiber.await()
     const verbs = b.entry()!.inject!(sid('s1'))
     b.unmountGoals()
 
-    for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
-      expect(result).toMatchObject({ ok: false, error: { code: 'internal' } })
+    // A missing namespace is an assembly fault, not a call outcome: this plugin
+    // declares remote.goals in `inject`, so cordis disposes the dock entry along
+    // with the namespace. Only a React closure that outlived that disposal can
+    // reach these verbs, so no consumer-side guard renders it as an error.
+    for (const verb of [() => verbs.onEdit('x'), () => verbs.onPause(), () => verbs.onResume(), () => verbs.onClear()]) {
+      await expect(verb()).rejects.toThrow(TypeError)
     }
     expect(b.calls).toHaveLength(0)
   })
@@ -179,30 +181,17 @@ describe('ui-goal browser plugin', () => {
       await b.fiber.await()
       const verbs = b.entry()!.inject!(sid('s1'))
       for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
-        expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } })
+        expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} } })
       }
       expect(b.calls).toHaveLength(0)
     }
   })
 
-  it('maps a Remote error onto the inline-render shape', async () => {
-    const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
-    await b.fiber.await()
-    const verbs = b.entry()!.inject!(sid('s1'))
-    expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } })
-  })
-
-  it.each([
-    [new Error('connection closed'), 'connection closed'],
-    ['connection closed', 'goal mutation failed'],
-    [new Error('invalid Remote failure', { cause: null }), 'invalid Remote failure'],
-    [new Error('invalid Remote failure', { cause: { code: 1, message: 'stale revision' } }), 'invalid Remote failure'],
-    [new Error('invalid Remote failure', { cause: { code: 'internal', message: 1 } }), 'invalid Remote failure'],
-  ])('maps an unstructured rejection onto an internal error', async (rejection, message) => {
-    const b = await bench({ projection: makeProjection(), rejectWith: rejection })
+  it('forwards a Remote failure to the strip verbatim', async () => {
+    const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision', details: {} } })
     await b.fiber.await()
     const verbs = b.entry()!.inject!(sid('s1'))
-    expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message } })
+    expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision', details: {} } })
   })
 
   it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
@@ -223,10 +212,10 @@ describe('GoalDock adapter', () => {
     const projection = makeProjection()
     const useProjection = vi.fn(() => projection)
     const actions: GoalBarActions = {
-      onEdit: () => Promise.resolve({ ok: true }),
-      onPause: () => Promise.resolve({ ok: true }),
-      onResume: () => Promise.resolve({ ok: true }),
-      onClear: () => Promise.resolve({ ok: true }),
+      onEdit: () => Promise.resolve({ ok: true, value: undefined }),
+      onPause: () => Promise.resolve({ ok: true, value: undefined }),
+      onResume: () => Promise.resolve({ ok: true, value: undefined }),
+      onClear: () => Promise.resolve({ ok: true, value: undefined }),
     }
     const t = makeTranslate(zh, commonZh)
     const dockProps = (up: () => GoalProjection | null | undefined) =>

+ 8 - 8
packages/client/ui-goal/tests/goalbar.spec.tsx

@@ -30,10 +30,10 @@ function makeGoal(over: Partial<GoalSnapshot> = {}): GoalSnapshot {
 
 function makeActions() {
   return {
-    onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true })),
-    onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true })),
-    onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true })),
-    onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true })),
+    onEdit: vi.fn<GoalBarActions['onEdit']>(() => Promise.resolve({ ok: true, value: undefined })),
+    onPause: vi.fn<GoalBarActions['onPause']>(() => Promise.resolve({ ok: true, value: undefined })),
+    onResume: vi.fn<GoalBarActions['onResume']>(() => Promise.resolve({ ok: true, value: undefined })),
+    onClear: vi.fn<GoalBarActions['onClear']>(() => Promise.resolve({ ok: true, value: undefined })),
   } satisfies GoalBarActions
 }
 
@@ -75,7 +75,7 @@ describe('GoalBar', () => {
     expect(actions.onClear).toHaveBeenCalledTimes(1)
     expect(clear.disabled).toBe(true)
 
-    await act(async () => { resolveClear({ ok: true }) })
+    await act(async () => { resolveClear({ ok: true, value: undefined }) })
     expect(container.firstChild).toBeNull()
 
     rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'Next goal' })} {...actions} t={t} />)
@@ -178,7 +178,7 @@ describe('GoalBar', () => {
 
   it('keeps the edit draft open and reports a failed save', async () => {
     const actions = makeActions()
-    actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision' } })
+    actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision', details: {} } })
     render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
     fireEvent.click(screen.getByRole('button', { name: '编辑目标' }))
     const box = screen.getByRole('textbox', { name: '目标内容' })
@@ -191,12 +191,12 @@ describe('GoalBar', () => {
 
   it('reports resume and clear failures without hiding the goal', async () => {
     const actions = makeActions()
-    actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed' } })
+    actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed', details: {} } })
     const { rerender } = render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} t={t} />)
     fireEvent.click(screen.getByRole('button', { name: '恢复目标' }))
     expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)')
 
-    actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed' } })
+    actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed', details: {} } })
     rerender(<GoalBar goal={makeGoal()} {...actions} t={t} />)
     fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
     expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)')

+ 4 - 1
packages/host/directory-picker-auto/src/index.ts

@@ -42,7 +42,10 @@ export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
 /**
  * Client surface package per resolved kind, mounted with its backend so one
  * resolved interaction still composes both faces. Declared as dependencies by
- * every composing app for the same reason as {@link BACKEND_PACKAGES}.
+ * every composing app for the same reason as {@link BACKEND_PACKAGES}. Only the
+ * specifier is referenced here — the packages belong to the Client program, so
+ * no import of them exists on this side and knip needs them ignored for this
+ * workspace.
  */
 export const SURFACE_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
   native: '@deepseek-ai/dsh-client-ui-directory-picker-native',