From 804b1ffbfcefa87f42ca2ed001332403bac5dc0a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:17:27 +0800 Subject: [PATCH] refactor(api): converge the Remote failure vocabulary and client surface Single RemoteError with a merge-extensible, domain-prefixed code map; owners throw at the failure point; streams surface marked failures; clients consume ctx.remote directly with isRemoteFailure as the only discrimination point and construct no failure instances. --- apps/web/tests/default-model.e2e.ts | 2 +- packages/api/gateway/package.json | 3 + packages/api/gateway/src/client/index.ts | 54 +++- .../api/gateway/src/client/journal-stream.ts | 38 +-- .../api/gateway/src/client/snapshot-stream.ts | 10 +- .../api/gateway/src/client/stream-client.ts | 22 +- packages/api/gateway/src/index.ts | 130 +++++----- .../api/gateway/src/remote-error-codes.ts | 35 +++ packages/api/gateway/src/types.ts | 34 +-- .../gateway/tests/gateway-stream.host.spec.ts | 27 +- .../api/gateway/tests/gateway.client.spec.ts | 40 +-- .../api/gateway/tests/gateway.host.spec.ts | 94 +++---- packages/api/gateway/tsconfig.client.json | 1 + packages/api/gateway/tsconfig.host.json | 1 + packages/api/remotes/src/client/index.ts | 27 +- packages/api/session-controller/package.json | 2 + packages/api/session-controller/src/agent.ts | 43 ++-- .../src/client/contract/result.ts | 29 --- .../src/client/contract/session.ts | 11 +- .../src/client/contract/sessions.ts | 4 +- .../src/client/contract/snapshot.ts | 6 +- .../session-controller/src/client/index.ts | 9 +- .../src/client/sessions/manager.ts | 141 +++++------ .../src/client/sessions/service.ts | 8 +- .../src/client/sessions/session.ts | 189 +++++--------- .../src/client/transport.ts | 27 +- .../api/session-controller/src/commands.ts | 143 +++++------ .../api/session-controller/src/history.ts | 36 ++- packages/api/session-controller/src/index.ts | 30 +-- packages/api/session-controller/src/list.ts | 22 +- .../session-controller/src/skill-catalog.ts | 33 +-- packages/api/session-controller/src/types.ts | 76 +++--- .../tests/agent.host.spec.ts | 13 +- .../tests/client-apply.client.spec.ts | 4 +- .../tests/client-contract.client.spec.ts | 11 - .../tests/commands-create-fork.host.spec.ts | 39 +-- .../commands-queue-attachment.host.spec.ts | 22 +- .../tests/controller.host.spec.ts | 3 +- .../tests/fake-api.client.ts | 113 ++++----- .../tests/manager.client.spec.ts | 89 +++---- .../tests/session-cold.host.spec.ts | 35 ++- .../tests/session-fork.host.spec.ts | 4 +- .../tests/session-models.host.spec.ts | 42 ++-- .../session-open-workspace-path.host.spec.ts | 14 +- ...session-pending-submissions.client.spec.ts | 5 +- .../tests/session-presets.host.spec.ts | 17 +- .../tests/session-rename.host.spec.ts | 6 +- .../tests/session-search.host.spec.ts | 36 +-- .../tests/session-skills.host.spec.ts | 14 +- .../tests/session.client.spec.ts | 121 +++++---- .../tests/sessions-service.client.spec.ts | 49 ++-- .../session-controller/tests/test-remote.ts | 18 +- .../tests/transport.client.spec.ts | 19 +- .../tests/transport.host.spec.ts | 32 +-- .../api/session-controller/tsconfig.host.json | 1 + .../settings-controller/src/credentials.ts | 40 ++- packages/api/settings-controller/src/index.ts | 150 ++++------- packages/api/settings-controller/src/types.ts | 58 ++--- .../tests/credentials-controller.host.spec.ts | 23 +- .../tests/settings-controller.host.spec.ts | 81 +++--- .../workspace-controller/src/client/index.ts | 15 +- .../workspace-controller/src/client/model.ts | 40 +-- .../src/client/service.ts | 2 +- .../api/workspace-controller/src/commands.ts | 40 ++- .../src/directory-picker.ts | 55 ++-- .../api/workspace-controller/src/types.ts | 61 ++--- .../tests/directory-picker.host.spec.ts | 29 +-- .../tests/model.client.spec.ts | 70 ++---- .../tests/transport.client.spec.ts | 51 ++-- .../tests/workspace-controller.host.spec.ts | 43 ++-- packages/client/connection/src/client/api.ts | 2 - .../client/connection/src/client/fixture.ts | 72 +++--- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/src/rpc-host.ts | 4 +- packages/client/connection/src/rpc.ts | 29 +-- .../tests/api-helpers.client.spec.ts | 2 +- .../tests/fixture-commands.client.spec.ts | 6 +- .../connection/tests/fixture.client.spec.ts | 46 ++-- .../connection/tests/node-half.host.spec.ts | 4 +- .../connection/tests/rpc-schema.host.spec.ts | 4 +- packages/client/locale/src/client/index.ts | 2 +- .../client/locale/tests/apply.client.spec.ts | 3 +- .../tests/document-language.client.spec.ts | 1 - .../locale/tests/invariant.client.spec.ts | 3 +- .../ui-agent-preset/src/client/index.ts | 7 +- .../ui-agent-preset/src/client/seat-store.ts | 49 ++-- .../src/client/section-store.ts | 106 ++++---- .../src/client/settings-store.ts | 83 +++---- .../tests/apply.client.spec.ts | 51 ++-- .../tests/section-store.client.spec.ts | 235 +++++++----------- .../tests/settings-store.client.spec.ts | 229 ++++++----------- .../tests/apply-inject.client.spec.tsx | 4 +- .../tests/chat-branch-tails.client.spec.tsx | 3 +- .../ui-chat/tests/chat-stats.client.spec.tsx | 3 +- .../ui-chat/tests/chat-view.client.spec.tsx | 5 +- .../tests/gate-branch-tails.client.spec.tsx | 3 +- .../client/ui-commands/src/client/service.ts | 10 +- .../ui-commands/tests/service.client.spec.ts | 14 +- .../src/client/image-labels.ts | 5 +- .../ui-conversation/src/client/input/hub.ts | 8 +- .../ui-conversation/src/client/service.ts | 2 +- .../src/client/skeleton/InputBar.tsx | 12 +- .../tests/apply-inject.client.spec.tsx | 5 +- .../tests/assembly-surfaces.client.spec.tsx | 11 +- .../tests/enter-behavior-row.client.spec.tsx | 3 +- .../tests/historical-images.client.spec.ts | 6 +- .../tests/input-bar.client.spec.tsx | 32 ++- .../service-orchestration.client.spec.ts | 30 +-- .../tests/skeleton.client.spec.tsx | 4 +- .../tests/todo-panel.client.spec.tsx | 3 +- .../ui-deliverables/src/client/index.ts | 8 +- .../tests/produced-files.client.spec.tsx | 29 ++- packages/client/ui-goal/src/client/index.ts | 2 +- packages/client/ui-goal/src/client/slots.ts | 13 +- .../tests/browser-plugin.client.spec.tsx | 14 +- .../ui-goal/tests/goalbar.client.spec.tsx | 18 +- .../src/client/controller.ts | 87 ++----- .../ui-message-feedback/src/client/index.ts | 4 +- .../ui-message-feedback/src/client/slots.ts | 2 +- .../tests/browser-plugin.client.spec.tsx | 2 +- .../tests/controller.client.spec.ts | 234 +++++++---------- .../message-feedback-actions.client.spec.tsx | 2 +- .../ui-model-selection/src/client/catalog.ts | 12 +- .../ui-model-selection/src/client/service.ts | 2 +- .../tests/catalog.client.spec.ts | 12 +- .../tests/model-select.client.spec.tsx | 4 +- .../ui-permission-presets/src/client/index.ts | 2 +- .../src/client/settings-store.ts | 31 ++- .../tests/browser-plugin.client.spec.ts | 13 +- .../permission-presets-row.client.spec.tsx | 14 +- .../tests/settings-store.client.spec.ts | 48 ++-- .../tests/browser-plugin.client.spec.ts | 5 +- .../tests/plan-mode-control.client.spec.tsx | 3 +- packages/client/ui-reference/package.json | 1 + .../client/ui-reference/src/client/index.ts | 18 +- .../tests/browser-plugin.client.spec.ts | 26 +- .../client/ui-settings-general/package.json | 3 - .../ui-settings-general/src/client/index.ts | 10 +- .../src/client/settings-document-store.ts | 22 +- .../tests/apply.client.spec.ts | 9 +- .../tests/components.client.spec.tsx | 18 +- .../settings-document-store.client.spec.ts | 51 ++-- .../tests/shell.client.spec.ts | 12 +- .../src/client/CustomProviderCard.tsx | 26 +- .../src/client/DeepSeekOnboardingDialog.tsx | 11 +- .../src/client/ModelListEditor.tsx | 14 +- .../src/client/ModelsSection.tsx | 57 ++--- .../src/client/ProviderEditor.tsx | 42 ++-- .../ui-settings-models/src/client/index.ts | 15 +- .../ui-settings-models/src/client/store.ts | 105 +++----- .../tests/apply.client.spec.ts | 7 +- .../tests/components.client.spec.tsx | 167 +++++++------ .../tests/onboarding-dialog.client.spec.tsx | 21 +- .../tests/provider-form.client.spec.tsx | 83 ++++--- .../tests/store.client.spec.ts | 103 +++----- .../tests/welcome-notice.client.spec.tsx | 18 +- .../tests/welcome-store.client.spec.ts | 18 +- .../client/ui-settings-plugins/package.json | 3 - .../ui-settings-plugins/src/client/index.ts | 6 +- ...ubagent-model-selection-card-controller.ts | 21 +- .../src/client/web-search-card-controller.ts | 30 +-- .../tests/apply.client.spec.ts | 13 +- .../tests/stores.client.spec.ts | 83 ++++--- packages/client/ui-settings/package.json | 5 +- .../client/ui-settings/src/client/index.ts | 25 +- .../ui-settings/src/client/settings-mirror.ts | 26 +- .../ui-settings/src/client/settings-scope.ts | 44 ++-- .../ui-settings/tests/plugin.client.spec.ts | 1 - .../tests/settings-mirror.client.spec.ts | 38 +-- .../tests/settings-scope.client.spec.ts | 66 ++--- packages/client/ui-skill/src/client/index.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 16 +- .../tests/browser-plugin.client.spec.ts | 1 - .../tests/conversation-ui.client.spec.tsx | 4 +- packages/client/ui-theme/src/client/index.ts | 2 +- .../ui-theme/tests/apply.client.spec.ts | 4 +- .../ui-theme/tests/invariant.client.spec.ts | 2 +- packages/client/ui-tool/src/client/apply.ts | 9 +- .../ui-tool/src/client/contract/slots.ts | 13 +- packages/client/ui-tool/src/client/index.ts | 2 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 3 +- .../ui-tool/src/client/tool/ToolDetails.tsx | 5 +- .../tests/assembly-surfaces.client.spec.tsx | 4 - .../tests/chat-code-subcalls.client.spec.tsx | 4 - .../tests/coverage-tails.client.spec.tsx | 3 +- .../ui-tool/tests/read-card.client.spec.tsx | 2 +- .../tests/tool-call-tree.client.spec.tsx | 7 +- .../tests/tool-details-render.client.tsx | 7 +- .../tests/toolview-slot.client.spec.tsx | 8 - .../ui-trajectory/tests/views.client.spec.tsx | 3 +- .../ui-workspace/src/client/contract/slots.ts | 8 +- .../client/ui-workspace/src/client/index.ts | 8 +- .../ui-workspace/src/client/navigation.ts | 3 +- .../src/client/rows/WorkspaceBrowser.tsx | 3 +- .../ui-workspace/tests/apply.client.spec.ts | 11 +- .../tests/rename-assembly.client.spec.tsx | 7 +- .../tests/workspace-browser.client.spec.tsx | 7 +- .../tests/workspace-picker.client.spec.tsx | 3 +- .../tests/workspaces-service.client.spec.ts | 13 +- packages/core/session/src/types.ts | 7 + .../src/client/TeamAction.tsx | 8 +- .../tests/browser-plugin.client.spec.ts | 11 +- .../tests/team-action.client.spec.tsx | 18 +- .../tests/transport/tunnel-client.spec.ts | 4 +- .../cordis-client-runner/package.json | 2 - .../src/client/inspect-registry.ts | 3 +- .../src/client/orchestrator.ts | 2 +- .../src/client/runtime.ts | 2 +- .../tests/orchestrator.client.spec.ts | 3 +- .../tests/plugin.client.spec.ts | 3 +- .../tests/runner.client.spec.ts | 3 +- .../cordis-client-runner/tsconfig.json | 3 - .../ui-cordis/src/client/CordisPanel.tsx | 2 +- .../ui-cordis/src/client/dynamic-port.ts | 2 +- .../ui-cordis/src/client/run-card-index.ts | 2 +- .../extensions/ui-cordis/src/client/slots.ts | 2 +- packages/llm/llm/src/index.ts | 15 +- packages/llm/llm/src/types.ts | 14 +- packages/llm/llm/tests/topology.spec.ts | 16 +- .../preset/agent-presets/src/authoring.ts | 77 +++--- packages/preset/agent-presets/src/index.ts | 152 ++++------- packages/preset/agent-presets/src/mount.ts | 11 +- packages/preset/agent-presets/src/preset.ts | 49 ---- packages/preset/agent-presets/src/types.ts | 34 +-- .../preset/agent-presets/tests/mount.spec.ts | 11 +- .../preset/agent-presets/tests/remote.spec.ts | 75 +++--- packages/subagent/subagent/package.json | 4 + .../subagent/subagent/src/control-types.ts | 62 +++-- packages/subagent/subagent/src/control.ts | 116 ++++----- packages/subagent/subagent/src/index.ts | 43 ++-- .../subagent/subagent/tests/control.spec.ts | 69 +++-- packages/subagent/subagent/tsconfig.json | 6 + .../test-support/client-runtime/package.json | 2 + .../test-support/client-runtime/src/index.ts | 2 +- .../test-support/client-runtime/src/remote.ts | 13 +- .../client-runtime/src/settings-remote.ts | 2 +- .../tests/remote.client.spec.ts | 2 +- .../test-support/client-runtime/tsconfig.json | 3 + packages/typert/protocol/src/index.ts | 41 +-- packages/typert/protocol/src/remote-error.ts | 46 ++++ packages/typert/protocol/src/types.ts | 28 ++- packages/util/time/package.json | 42 ++++ packages/util/time/src/index.ts | 33 +++ packages/util/time/src/invariant.ts | 30 +++ packages/util/time/tests/time.spec.ts | 29 +++ packages/util/time/tsconfig.json | 15 ++ packages/workspace/workspace/package.json | 2 + packages/workspace/workspace/src/types.ts | 8 + packages/workspace/workspace/tsconfig.json | 3 + pnpm-lock.yaml | 39 ++- tsconfig.base.json | 4 + tsconfig.host.json | 1 + 252 files changed, 3182 insertions(+), 3832 deletions(-) create mode 100644 packages/api/gateway/src/remote-error-codes.ts delete mode 100644 packages/api/session-controller/src/client/contract/result.ts create mode 100644 packages/typert/protocol/src/remote-error.ts create mode 100644 packages/util/time/package.json create mode 100644 packages/util/time/src/index.ts create mode 100644 packages/util/time/src/invariant.ts create mode 100644 packages/util/time/tests/time.spec.ts create mode 100644 packages/util/time/tsconfig.json diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index a041f85d14..bb3fe582d9 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -148,7 +148,7 @@ describe('web e2e: the composer model switch is the default for later sessions', sessionId: SessionId(await createSession('default-model-refusal')), mode: 'queue', content: [{ type: 'text', text: 'hi' }], - }, new AbortController().signal)).rejects.toMatchObject({ failure: { code: 'model-unavailable' } }) + }, new AbortController().signal)).rejects.toMatchObject({ code: 'session/model-unavailable' }) // The way out stays open. Locking the model seat with everything else // would leave the composer asking for the one thing it prevents. diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d5601ab188..e961defbad 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -35,6 +35,9 @@ }, "dsh": { "client": { + "external": [ + "@deepseek-ai/dsh-typert-protocol" + ], "inject": [ "@deepseek-ai/dsh-typert-registry", "@deepseek-ai/dsh-client-connection" diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 4fe46e6d2d..e52bcd0f31 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,6 +5,8 @@ */ import { Service } from '@deepseek-ai/cordis' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, @@ -13,6 +15,7 @@ import type { InvocationDescriptor, TypertClientEventListener, TypertClientRemote, + RemoteFailure, RemoteResult, TypertCodec, TypertDisposer, @@ -21,7 +24,6 @@ import type { } from '@deepseek-ai/dsh-typert-protocol' import { RemoteStreamCarrierError, - RemoteStreamError, RemoteStreamMuxClient, } from './stream-client.ts' import { ClientRemoteEvents } from './remote-events.ts' @@ -30,7 +32,7 @@ import { type RemoteStreamOptions, } from './remote-stream.ts' -export { RemoteStreamCarrierError, RemoteStreamError } from './stream-client.ts' +export { RemoteStreamCarrierError } from './stream-client.ts' export { RemoteJournalStream } from './journal-stream.ts' export type { RemoteJournalChange, @@ -104,6 +106,20 @@ export interface ClientRemote extends TypertClientRemote { * @returns a single-consumer stream annotated with physical generation ids. */ $stream(options: RemoteStreamOptions): RemoteStream + /** + * Fixed Host facts as plain reads: no store, no subscription, no generation + * counter. `home` stays undefined until the first ready frame and reflects + * the latest one afterwards. + */ + readonly $host: RemoteHostFacts +} + +/** The fixed Host facts exposed on `ctx.remote.$host`. */ +export interface RemoteHostFacts { + /** Host home directory from the ready frame, undefined before it. */ + readonly home: string | undefined + /** Whether the carrier connects to the local Host. */ + readonly isLoopback: boolean } declare module '@deepseek-ai/cordis' { @@ -166,6 +182,14 @@ class ClientRemoteService extends Service implements ClientRemote { return new RemoteStream(this.connection, options) } + get $host(): RemoteHostFacts { + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined ?? this.connection + return { + home: connection.generation.getSnapshot()?.host.home, + isLoopback: connection.isLoopback, + } + } + async $mount(contribution: TypertRemoteContribution): ReturnType { const callerCtx = this.ctx const owned = callerCtx.effect(async () => { @@ -410,7 +434,7 @@ class ClientRemoteService extends Service implements ClientRemote { try { const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal) if (!mountActive(token)) return withdrawn(endpoint) - if (!result.ok) return { ok: false, error: result.error } + if (!result.ok) return { ok: false, error: rebuiltFailure(result.error) } return { ok: true, value: result.value } } catch (error) { // Carrier throws (offline or abort) are outcomes of the call, not assembly @@ -698,7 +722,27 @@ function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { - return { ok: false, error: { code: 'internal', message, details: {} } } + return { ok: false, error: new RemoteError('gateway/internal', message, {}) } +} + +/** + * Whether a caught value is a Remote failure this face delivered or threw. + * The one consumer-facing discrimination point: marked instances carry their + * Host code; anything else is a local fault the caller should let crash. + * @param error - a caught value. + * @returns true when the value narrows to RemoteFailure. + */ +export function isRemoteFailure(error: unknown): error is RemoteFailure { + return remoteErrorOf(error) !== undefined +} + +/** + * Rebuild the wire failure as a local RemoteError instance so the error branch + * carries a real Error and `throw result.error` keeps throw semantics. The wire + * is a validation boundary: codes outside the merged map surface as-is. + */ +function rebuiltFailure(error: { code: string; message: string; details: object }): RemoteFailure { + return new RemoteError(error.code as never, error.message, error.details as never) } type MarkedConnectionStreamFailure = Error & { @@ -715,7 +759,7 @@ async function *normalizeConnectionStream(source: AsyncIterable): Async if (!(error instanceof Error)) throw error const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure if (marker?.kind === 'remote') { - throw new RemoteStreamError(marker.code, error.message, marker.details) + throw new RemoteError(marker.code as never, error.message, marker.details as never) } if (marker?.kind === 'carrier') { throw new RemoteStreamCarrierError(error.message, { cause: error }) diff --git a/packages/api/gateway/src/client/journal-stream.ts b/packages/api/gateway/src/client/journal-stream.ts index 7a6f2e8166..cde54083f6 100644 --- a/packages/api/gateway/src/client/journal-stream.ts +++ b/packages/api/gateway/src/client/journal-stream.ts @@ -1,5 +1,6 @@ /** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { RemoteStreamCarrierError } from './stream-client.ts' import type { RemoteStream, @@ -7,6 +8,11 @@ import type { RemoteStreamOptions, } from './remote-stream.ts' +/** Host-side stream protocol violation, marked so consumers surface it as an error state. */ +function protocolViolation(message: string): RemoteError<'gateway/internal'> { + return new RemoteError('gateway/internal', message, {}) +} + /** Transport-neutral opening snapshot or journal entry. */ export type RemoteJournalFrame = | { readonly type: 'opened'; readonly cursor: Cursor; readonly page: Page } @@ -100,7 +106,7 @@ export abstract class RemoteJournalStream this.follow(this.initialRequest, signal), ended: accepted => accepted ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) - : new Error( + : protocolViolation( `${this.hasResumeCursor ? 'resumed ' : ''}${options.name} ended before its opening cursor`, ), ...(options.carrierFailed === undefined @@ -153,7 +159,7 @@ export abstract class RemoteJournalStream 0) { - throw new Error(`${this.options.name} entry has an inverted cursor range`) + throw protocolViolation(`${this.options.name} entry has an inverted cursor range`) } return { first, last } } @@ -534,7 +540,7 @@ export abstract class RemoteJournalStream { + return new RemoteError('gateway/internal', message, {}) +} + /** Domain operations for one snapshot stream. */ export interface RemoteSnapshotStreamOptions { /** Diagnostic stream name used in protocol failures. */ @@ -69,7 +75,7 @@ export class RemoteSnapshotStream { } if (this.options.isSnapshot(item.value)) { if (snapshotSeen) { - throw new Error(`${this.options.name} emitted more than one opening snapshot`) + throw protocolViolation(`${this.options.name} emitted more than one opening snapshot`) } this.options.replace(item.value) snapshotSeen = true @@ -77,7 +83,7 @@ export class RemoteSnapshotStream { continue } if (!snapshotSeen) { - throw new Error(`${this.options.name} emitted an update before its opening snapshot`) + throw protocolViolation(`${this.options.name} emitted an update before its opening snapshot`) } this.options.update(item.value) } diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 0dd56cc72d..9616102cce 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -1,3 +1,4 @@ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' /** Browser owner for the Gateway multiplexed Remote stream socket. */ import { @@ -14,25 +15,6 @@ const RECONNECT_FACTOR = 2 const RECONNECT_MAX_MS = 10_000 /** One Host-reported Remote stream failure. */ -export class RemoteStreamError extends Error { - /** Stable carrier or Gateway error category. */ - readonly code: string - /** Host-provided structured failure context. */ - readonly details: object - - /** - * @param code - stable Gateway or business error category. - * @param message - Host-provided failure description. - * @param details - Host-provided structured failure context. - */ - constructor(code: string, message: string, details: object) { - super(message) - this.name = 'RemoteStreamError' - this.code = code - this.details = details - } -} - /** Physical Remote stream socket failure that may be retried by a domain transport. */ export class RemoteStreamCarrierError extends Error { /** @@ -105,7 +87,7 @@ export class RemoteStreamMuxClient { } terminal = true if (frame.type === 'error') { - throw new RemoteStreamError(frame.error.code, frame.error.message, frame.error.details) + throw new RemoteError(frame.error.code as never, frame.error.message, frame.error.details as never) } return } diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 1d754b05a7..8610dc2881 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -11,10 +11,11 @@ import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import z from '@deepseek-ai/schemastery' +export type { TypertGatewayFaultDetails } from './remote-error-codes.ts' import { + RemoteError, + remoteErrorOf, remoteMethods, - TypertLookupFailure, - TypertRemoteFailure, type InvocationDescriptor, type InvocationParameterDescriptor, type TypertCodec, @@ -123,10 +124,12 @@ interface ResolvedConfig extends Config { readonly websocketHeartbeatIntervalMs: number } -/** Dispatch failure produced outside the invoked business method. */ -export class TypertGatewayError extends Error { - /** Machine-readable failure category. */ - readonly code: TypertGatewayErrorCode +/** + * Dispatch failure produced outside the invoked business method. Rides the + * shared Remote failure vocabulary, so its code crosses the wire instead of + * folding to `internal`. + */ +export class TypertGatewayError extends RemoteError { /** Canonical `/` endpoint. */ readonly endpoint: string /** Affected wire field when the failure is field-specific. */ @@ -145,26 +148,18 @@ export class TypertGatewayError extends Error { message: string, options: GatewayErrorOptions = {}, ) { - super(`typert gateway: ${endpoint}: ${message}`, options.cause === undefined ? undefined : { cause: options.cause }) + super( + code, + `typert gateway: ${endpoint}: ${message}`, + { endpoint, ...options.field === undefined ? {} : { field: options.field } }, + options.cause === undefined ? undefined : { cause: options.cause }, + ) this.name = 'TypertGatewayError' - this.code = code this.endpoint = endpoint this.field = options.field } } -/** Business invocation lost its carrier cancellation race. */ -class RemoteInvocationCancelled extends Error { - /** - * @param endpoint - canonical Remote endpoint. - * @param cause - business rejection observed after carrier cancellation. - */ - constructor(endpoint: string, cause: unknown) { - super(`Remote invocation "${endpoint}" was aborted`, { cause }) - this.name = 'RemoteInvocationCancelled' - } -} - /** * Resolve strict generated definitions or conservative SRC markers against * current Cordis Services and Typert providers. @@ -303,7 +298,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const prepared = await this.prepareInvocation(request) if (prepared.descriptor.mode === 'stream') { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', prepared.endpoint, 'stream Remote methods must be opened through the stream carrier', ) @@ -312,7 +307,7 @@ export class TypertGatewayService extends Service implements TypertGateway { try { return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) throw error } } @@ -326,7 +321,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const prepared = await this.prepareInvocation(request) if (prepared.descriptor.mode !== 'stream') { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', prepared.endpoint, 'unary Remote methods cannot be opened through the stream carrier', ) @@ -335,12 +330,12 @@ export class TypertGatewayService extends Service implements TypertGateway { try { source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) throw error } if (!isIterable(source)) { throw new TypertGatewayError( - 'result-invalid', + 'gateway/result-invalid', prepared.endpoint, 'stream Remote method did not return Iterable or AsyncIterable', { field: 'result' }, @@ -400,7 +395,7 @@ export class TypertGatewayService extends Service implements TypertGateway { || !isPlainObject(payload.args) || Reflect.ownKeys(payload.args).length !== 0) { throw new TypertGatewayError( - 'arguments-invalid', + 'gateway/arguments-invalid', REMOTE_EVENT_STREAM_ENDPOINT, 'forwarded Remote event stream requires an empty args object', ) @@ -408,7 +403,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const registration = this.remoteEvents if (registration === undefined) { throw new TypertGatewayError( - 'service-unavailable', + 'gateway/service-unavailable', REMOTE_EVENT_STREAM_ENDPOINT, 'forwarded Remote event source is unavailable', ) @@ -611,7 +606,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const receiver = receiverContext.get(descriptor.service) as unknown if (!isObject(receiver)) { throw new TypertGatewayError( - 'service-unavailable', + 'gateway/service-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} is unavailable`, ) @@ -624,7 +619,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const method = Reflect.get(receiver, implementation) as unknown if (typeof method !== 'function') { throw new TypertGatewayError( - 'method-unavailable', + 'gateway/method-unavailable', endpoint, `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, ) @@ -637,7 +632,7 @@ export class TypertGatewayService extends Service implements TypertGateway { if (strict !== undefined) return strict if (this.ctx.typert.local.hasSeen(endpoint)) { throw new TypertGatewayError( - 'definition-unavailable', + 'gateway/definition-unavailable', endpoint, 'its strict definition was withdrawn and SRC fallback is forbidden', ) @@ -661,11 +656,11 @@ export class TypertGatewayService extends Service implements TypertGateway { candidates.push(this.srcDescriptor(binding, marker, method, endpoint)) } if (candidates.length === 0) { - throw new TypertGatewayError('invocation-unavailable', endpoint, 'no active Remote method exports this endpoint') + throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'no active Remote method exports this endpoint') } if (candidates.length > 1) { throw new TypertGatewayError( - 'ambiguous-endpoint', + 'gateway/ambiguous-endpoint', endpoint, `multiple active Services export this endpoint: ${candidates.map(candidate => candidate.service).sort().join(', ')}`, ) @@ -683,7 +678,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const signalIndex = names.indexOf('signal') if (signalIndex >= 0 && signalIndex !== names.length - 1) { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, 'SRC cancellation parameter signal must be the final parameter', { field: 'signal' }, @@ -700,7 +695,7 @@ export class TypertGatewayService extends Service implements TypertGateway { .filter(definition => definition.parameter === name) if (matches.length > 1) { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, `parameter ${JSON.stringify(name)} matches multiple lookup providers`, { field: name }, @@ -718,7 +713,7 @@ export class TypertGatewayService extends Service implements TypertGateway { } if (wires.has(parameter.wire)) { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`, { field: parameter.wire }, @@ -733,14 +728,14 @@ export class TypertGatewayService extends Service implements TypertGateway { const provider = this.ctx.typert.contexts.getHost(marker.invocation.context) if (provider === undefined) { throw new TypertGatewayError( - 'context-unavailable', + 'gateway/context-unavailable', endpoint, `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`, ) } if (wires.has(provider.wire)) { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`, { field: provider.wire }, @@ -778,7 +773,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const provider = this.ctx.typert.contexts.getHost(invocation.context) if (provider === undefined) { throw new TypertGatewayError( - 'context-unavailable', + 'gateway/context-unavailable', endpoint, `Context provider ${JSON.stringify(invocation.context)} is unavailable`, ) @@ -786,7 +781,7 @@ export class TypertGatewayService extends Service implements TypertGateway { if (provider.wire !== invocation.wire || (invocation.codec.mode === 'strict' && provider.wireTypeSymbol !== invocation.codec.typeSymbol)) { throw new TypertGatewayError( - 'provider-mismatch', + 'gateway/provider-mismatch', endpoint, `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`, { field: invocation.wire }, @@ -797,9 +792,9 @@ export class TypertGatewayService extends Service implements TypertGateway { try { context = await provider.resolve(identity) } catch (cause) { - if (cause instanceof TypertLookupFailure) throw cause + if (remoteErrorOf(cause) !== undefined) throw cause throw new TypertGatewayError( - 'context-failed', + 'gateway/context-failed', endpoint, `Context provider ${JSON.stringify(invocation.context)} failed`, { cause, field: invocation.wire }, @@ -807,7 +802,7 @@ export class TypertGatewayService extends Service implements TypertGateway { } if (context === undefined) { throw new TypertGatewayError( - 'context-not-found', + 'gateway/context-not-found', endpoint, `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`, { field: invocation.wire }, @@ -832,7 +827,7 @@ export class TypertGatewayService extends Service implements TypertGateway { /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ if (key === undefined) { throw new TypertGatewayError( - 'lookup-unavailable', + 'gateway/lookup-unavailable', endpoint, `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, { field: parameter.wire }, @@ -841,7 +836,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const provider = this.ctx.typert.lookups.get(key) if (provider === undefined) { throw new TypertGatewayError( - 'lookup-unavailable', + 'gateway/lookup-unavailable', endpoint, `lookup provider ${JSON.stringify(key)} is unavailable`, { field: parameter.wire }, @@ -850,7 +845,7 @@ export class TypertGatewayService extends Service implements TypertGateway { if (provider.wire !== parameter.wire || (parameter.codec.mode === 'strict' && provider.wireTypeSymbol !== parameter.codec.typeSymbol)) { throw new TypertGatewayError( - 'provider-mismatch', + 'gateway/provider-mismatch', endpoint, `lookup provider ${JSON.stringify(key)} does not match its strict definition`, { field: parameter.wire }, @@ -860,9 +855,9 @@ export class TypertGatewayService extends Service implements TypertGateway { try { resolved = await provider.resolve(value) } catch (cause) { - if (cause instanceof TypertLookupFailure) throw cause + if (remoteErrorOf(cause) !== undefined) throw cause throw new TypertGatewayError( - 'lookup-failed', + 'gateway/lookup-failed', endpoint, `lookup provider ${JSON.stringify(key)} failed`, { cause, field: parameter.wire }, @@ -870,7 +865,7 @@ export class TypertGatewayService extends Service implements TypertGateway { } if (resolved === undefined) { throw new TypertGatewayError( - 'lookup-not-found', + 'gateway/lookup-not-found', endpoint, `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, { field: parameter.wire }, @@ -978,11 +973,11 @@ async function *cancellableStream( let rejectAbort: ((error: unknown) => void) | undefined const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) const onAbort = (): void => { - rejectAbort?.(new RemoteInvocationCancelled(endpoint, signal.reason)) + rejectAbort?.(remoteCancelled(endpoint, signal.reason)) } signal.addEventListener('abort', onAbort, { once: true }) try { - if (signal.aborted) throw new RemoteInvocationCancelled(endpoint, signal.reason) + if (signal.aborted) throw remoteCancelled(endpoint, signal.reason) while (true) { const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) if (next.done === true) return @@ -994,23 +989,20 @@ async function *cancellableStream( } } +/** Carrier-signal cancellation as the shared failure vocabulary expresses it. */ +function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway/cancelled'> { + return new RemoteError('gateway/cancelled', `Remote invocation "${endpoint}" was aborted`, {}, { cause }) +} + function rpcFailure(error: unknown): ConnectionRpcResult { - if (error instanceof RemoteInvocationCancelled) { - return { - ok: false, - error: { code: 'cancelled', message: error.message, details: {} }, - } - } - if (error instanceof TypertLookupFailure) { - return { ok: false, error: error.failure as ConnectionRpcError } - } - if (error instanceof TypertRemoteFailure) { - return { ok: false, error: error.failure } + const remote = remoteErrorOf(error) + if (remote !== undefined) { + return { ok: false, error: { code: remote.code, message: remote.message, details: remote.details } } } return { ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: error instanceof Error ? error.message : String(error), details: {}, }, @@ -1035,7 +1027,7 @@ function validateBinding( const value = Reflect.get(original, 'typertRemote') as unknown if (value === undefined) { throw new TypertGatewayError( - 'binding-invalid', + 'gateway/binding-invalid', endpoint, `Service ${JSON.stringify(serviceKey)} has no visible typertRemote binding`, ) @@ -1059,7 +1051,7 @@ function readBinding( || typeof Reflect.get(value, 'namespace') !== 'string' || (namespace !== undefined && Reflect.get(value, 'namespace') !== namespace)) { throw new TypertGatewayError( - 'binding-invalid', + 'gateway/binding-invalid', endpoint, `Service ${JSON.stringify(serviceKey)} has an inconsistent typertRemote binding`, ) @@ -1087,7 +1079,7 @@ function methodParameterNames(service: object, method: string, endpoint: string) } if (implementation === undefined) { throw new TypertGatewayError( - 'method-unavailable', + 'gateway/method-unavailable', endpoint, `Remote marker has no prototype method ${JSON.stringify(method)}`, ) @@ -1110,7 +1102,7 @@ function methodParameterNames(service: object, method: string, endpoint: string) function invalidSignature(endpoint: string, method: string): never { throw new TypertGatewayError( - 'signature-invalid', + 'gateway/signature-invalid', endpoint, `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`, ) @@ -1122,7 +1114,7 @@ function assertExactArguments( endpoint: string, ): void { if (!isPlainObject(args)) { - throw new TypertGatewayError('arguments-invalid', endpoint, 'args must be a plain object') + throw new TypertGatewayError('gateway/arguments-invalid', endpoint, 'args must be a plain object') } const expected = new Set(descriptor.parameters.map(parameter => parameter.wire)) if (descriptor.invocation.kind === 'context') expected.add(descriptor.invocation.wire) @@ -1141,7 +1133,7 @@ function assertExactArguments( const clauses: string[] = [] if (missing.length > 0) clauses.push(`missing ${missing.map(key => JSON.stringify(key)).join(', ')}`) if (extra.length > 0) clauses.push(`unexpected ${extra.map(key => JSON.stringify(String(key))).join(', ')}`) - throw new TypertGatewayError('arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) + throw new TypertGatewayError('gateway/arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) } function decode( @@ -1160,7 +1152,7 @@ function decode( return value } catch (cause) { throw new TypertGatewayError( - 'input-invalid', + 'gateway/input-invalid', endpoint, `wire field ${JSON.stringify(field)} failed boundary validation`, { cause, field }, diff --git a/packages/api/gateway/src/remote-error-codes.ts b/packages/api/gateway/src/remote-error-codes.ts new file mode 100644 index 0000000000..22f94749f5 --- /dev/null +++ b/packages/api/gateway/src/remote-error-codes.ts @@ -0,0 +1,35 @@ +/** + * Gateway infrastructure failure codes merged into the shared Remote failure + * vocabulary. Face-neutral: the Host face and the Client face each import this + * module so both programs see the same map entries. + */ + +/** Wire details every Gateway infrastructure failure carries. */ +export interface TypertGatewayFaultDetails { + /** Canonical `/` endpoint. */ + readonly endpoint: string + /** Affected wire field when the failure is field-specific. */ + readonly field?: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'gateway/ambiguous-endpoint': TypertGatewayFaultDetails + 'gateway/arguments-invalid': TypertGatewayFaultDetails + 'gateway/binding-invalid': TypertGatewayFaultDetails + 'gateway/context-failed': TypertGatewayFaultDetails + 'gateway/context-not-found': TypertGatewayFaultDetails + 'gateway/context-unavailable': TypertGatewayFaultDetails + 'gateway/definition-unavailable': TypertGatewayFaultDetails + 'gateway/input-invalid': TypertGatewayFaultDetails + 'gateway/invocation-unavailable': TypertGatewayFaultDetails + 'gateway/lookup-failed': TypertGatewayFaultDetails + 'gateway/lookup-not-found': TypertGatewayFaultDetails + 'gateway/lookup-unavailable': TypertGatewayFaultDetails + 'gateway/method-unavailable': TypertGatewayFaultDetails + 'gateway/provider-mismatch': TypertGatewayFaultDetails + 'gateway/result-invalid': TypertGatewayFaultDetails + 'gateway/service-unavailable': TypertGatewayFaultDetails + 'gateway/signature-invalid': TypertGatewayFaultDetails + } +} diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 9b4475cb9a..b456efb4d0 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -99,23 +99,23 @@ export interface TypertGatewayWireStream { /** Stable infrastructure and boundary failures emitted before or after business execution. */ export type TypertGatewayErrorCode = - | 'ambiguous-endpoint' - | 'arguments-invalid' - | 'binding-invalid' - | 'context-failed' - | 'context-not-found' - | 'context-unavailable' - | 'definition-unavailable' - | 'input-invalid' - | 'invocation-unavailable' - | 'lookup-failed' - | 'lookup-not-found' - | 'lookup-unavailable' - | 'method-unavailable' - | 'provider-mismatch' - | 'result-invalid' - | 'service-unavailable' - | 'signature-invalid' + | 'gateway/ambiguous-endpoint' + | 'gateway/arguments-invalid' + | 'gateway/binding-invalid' + | 'gateway/context-failed' + | 'gateway/context-not-found' + | 'gateway/context-unavailable' + | 'gateway/definition-unavailable' + | 'gateway/input-invalid' + | 'gateway/invocation-unavailable' + | 'gateway/lookup-failed' + | 'gateway/lookup-not-found' + | 'gateway/lookup-unavailable' + | 'gateway/method-unavailable' + | 'gateway/provider-mismatch' + | 'gateway/result-invalid' + | 'gateway/service-unavailable' + | 'gateway/signature-invalid' /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index c769192a4b..07a76d685f 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -12,9 +12,16 @@ import { type InvocationDescriptor, type TypertContextMap, type TypertContextWire, - TypertRemoteFailure, + RemoteError, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'fixture/rejected': { readonly retryable: boolean } + 'fixture/broken': { readonly count: bigint } + } +} import { provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, @@ -118,16 +125,12 @@ class FeedService extends Service { @Remote({ mode: 'stream' }) reject(): Iterable { - throw new TypertRemoteFailure({ - code: 'fixture-rejected', message: 'fixture rejected the stream', details: { retryable: false }, - }) + throw new RemoteError('fixture/rejected', 'fixture rejected the stream', { retryable: false }) } @Remote({ mode: 'stream' }) rejectWithNonJsonDetails(): Iterable { - throw new TypertRemoteFailure({ - code: 'fixture-broken', message: 'fixture emitted invalid details', details: { count: 1n }, - }) + throw new RemoteError('fixture/broken', 'fixture emitted invalid details', { count: 1n }) } unary(label: string): string { @@ -262,7 +265,7 @@ describe('Typert Remote streams', () => { }))).resolves.toEqual([1n]) await expect(ctx.typertGateway.stream({ namespace: 'feed', method: 'missing', args: {}, - })).rejects.toMatchObject({ code: 'result-invalid' }) + })).rejects.toMatchObject({ code: 'gateway/result-invalid' }) await expect(collect(await ctx.typertGateway.stream({ namespace: 'feed', method: 'src', args: { label: 'c' }, @@ -286,10 +289,10 @@ describe('Typert Remote streams', () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ namespace: 'feed', method: 'sync', args: { label: 'a' }, - })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) await expect(ctx.typertGateway.stream({ namespace: 'feed', method: 'unary', args: { label: 'a' }, - })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) }) it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => { @@ -345,13 +348,13 @@ describe('Typert Remote streams', () => { { type: 'end', streamId: 'invalid' }, ]) expect(frames.find(frame => frame.streamId === 'non-json')).toMatchObject({ - type: 'error', error: { code: 'internal' }, + type: 'error', error: { code: 'gateway/internal' }, }) expect(frames.find(frame => frame.streamId === 'rejected')).toEqual({ type: 'error', streamId: 'rejected', error: { - code: 'fixture-rejected', + code: 'fixture/rejected', message: 'fixture rejected the stream', details: { retryable: false }, }, diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 3122fbffd5..519b4dc0cc 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1,3 +1,4 @@ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it, vi } from 'vitest' @@ -22,7 +23,6 @@ import type { ClientRemote } from '../src/client/index.ts' import { apply, inject, RemoteStream } from '../src/client/index.ts' import { RemoteStreamCarrierError, - RemoteStreamError, RemoteStreamMuxClient, } from '../src/client/stream-client.ts' @@ -634,10 +634,10 @@ describe('Client Typert API', () => { expect(ctx.get('remote.probe')).toBeUndefined() expect(ctx.get('probe')).toBe(businessProbe) expect(ctx.typert.remotes.list()).toEqual([]) - await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toEqual({ + await expect(retained?.('agent-1', { objective: 'ship' })).resolves.toMatchObject({ ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: 'client api: Remote method probe/create is no longer mounted', details: {}, }, @@ -1079,10 +1079,10 @@ describe('Client Typert API', () => { await dispose() resolveCall({ ok: true, value: { ref: 'goal-1' } }) - await expect(invocation).resolves.toEqual({ + await expect(invocation).resolves.toMatchObject({ ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: 'client api: Remote method probe/create is no longer mounted', details: {}, }, @@ -1235,14 +1235,14 @@ describe('Client Typert API', () => { }) it('delivers an RPC failure in the error branch with the Host error verbatim', async () => { - const rpcError = { code: 'internal' as const, message: 'host failed', details: {} } + const rpcError = { code: 'gateway/internal' as const, message: 'host failed', details: {} } const ctx = await bench(vi.fn().mockResolvedValue({ ok: false, error: rpcError })) await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) const outcome = await ctx.remote.probe.create('agent-1', { objective: 'ship' }) expect(outcome.ok).toBe(false) if (outcome.ok) throw new Error('expected the Client API invocation to report a failure') - expect(outcome.error).toBe(rpcError) + expect(outcome.error).toMatchObject(rpcError) }) it('folds a transport throw into the error branch', async () => { @@ -1250,10 +1250,10 @@ describe('Client Typert API', () => { .mockRejectedValue(new Error('carrier offline'))) await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) - await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({ ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: 'client api: probe/create failed: carrier offline', details: {}, }, @@ -1265,10 +1265,10 @@ describe('Client Typert API', () => { .mockRejectedValue('carrier exploded')) await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) - await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toMatchObject({ ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: 'client api: probe/create failed: carrier exploded', details: {}, }, @@ -1472,7 +1472,7 @@ describe('Client Typert API', () => { it('fails the Connection generation when a result RPC is rejected', async () => { const call = vi.fn().mockResolvedValue({ ok: false, - error: { code: 'internal', message: 'fixture result rejected', details: {} }, + error: { code: 'gateway/internal', message: 'fixture result rejected', details: {} }, }) const { client, carrier, run } = await eventBench(call) @@ -1554,7 +1554,7 @@ describe('Client Typert API', () => { }) target.remote.$on('fixture/approval', () => Promise.reject(rejection)) - carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'cancelled')) + carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'gateway/cancelled')) await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) expect(call).toHaveBeenCalledWith( @@ -1884,7 +1884,7 @@ describe('Client Typert API', () => { { name: 'Host failure', stop: (carrier: RemoteEventCarrier) => { - carrier.fail(new RemoteStreamError('internal', 'fixture Host failed', {})) + carrier.fail(new RemoteError('gateway/internal', 'fixture Host failed', {})) }, message: 'fixture Host failed', }, @@ -2037,14 +2037,14 @@ describe('Client Typert API', () => { failure: Object.assign(new Error('fixture Host rejected the stream'), { dshRemoteStreamFailure: { kind: 'remote' as const, - code: 'fixture-rejected', + code: 'fixture/rejected', details: { retry: false }, }, }), assert: (error: unknown) => { - expect(error).toBeInstanceOf(RemoteStreamError) + expect(error).toBeInstanceOf(RemoteError) expect(error).toMatchObject({ - code: 'fixture-rejected', + code: 'fixture/rejected', message: 'fixture Host rejected the stream', details: { retry: false }, }) @@ -2122,14 +2122,14 @@ describe('Client Typert API', () => { type: 'error', streamId: failedOpen.streamId, error: { - code: 'lookup-unavailable', + code: 'gateway/lookup-unavailable', message: 'fixture stream failed', details: { lookup: 'missing' }, }, }) await expect(failedItem).rejects.toMatchObject({ - name: 'RemoteStreamError', - code: 'lookup-unavailable', + name: 'RemoteError', + code: 'gateway/lookup-unavailable', message: 'fixture stream failed', details: { lookup: 'missing' }, }) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index ab1ee9d6b9..b61a6189f8 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -9,8 +9,8 @@ import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { bindTypertRemote, Remote, + RemoteError, RemoteScope, - TypertLookupFailure, type InvocationDescriptor, type TypertContext, type TypertLookup, @@ -37,6 +37,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertContextMap { gatewayFixture: TypertContext } + + interface RemoteErrorDetailsMap { + 'session/agent-busy': { readonly reason: string } + } } const emptyModel: TypertContribution['model'] = { @@ -452,7 +456,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'lookup-unavailable') + }), 'gateway/lookup-unavailable') expect(service.calls).toEqual([]) }) @@ -485,7 +489,7 @@ describe('TypertGatewayService', () => { })).resolves.toBe('land') await expectCode(ctx.typertGateway.invoke({ namespace: 'other', method: 'absent', args: {}, - }), 'invocation-unavailable') + }), 'gateway/invocation-unavailable') }) it('rejects SRC wire collisions and unavailable Context providers', async () => { @@ -496,14 +500,14 @@ describe('TypertGatewayService', () => { namespace: 'colliding-wire', method: 'run', args: { agentId: 'agent-1' }, - }), 'signature-invalid') + }), 'gateway/signature-invalid') const missing = await setup() await expectCode(missing.ctx.typertGateway.invoke({ namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'context-unavailable') + }), 'gateway/context-unavailable') const contextCollision = await setupGateway() await contextCollision.plugin(ContextWireService) @@ -512,7 +516,7 @@ describe('TypertGatewayService', () => { namespace: 'context-wire', method: 'run', args: { agentId: 'agent-1' }, - }), 'signature-invalid') + }), 'gateway/signature-invalid') }) it('re-reads Service and providers on every strict invocation', async () => { @@ -526,7 +530,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'lookup-unavailable') + }), 'gateway/lookup-unavailable') registerAgentLookup(ctx, agent) await serviceFiber.dispose() @@ -534,7 +538,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'service-unavailable') + }), 'gateway/service-unavailable') }) it('re-reads and contains Context providers', async () => { @@ -548,7 +552,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'context-unavailable') + }), 'gateway/context-unavailable') ctx.typert.contexts.registerHost('gatewayFixture', { ...contextProvider(scoped), @@ -558,13 +562,13 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'context-failed') + }), 'gateway/context-failed') expect(error.cause).toEqual(new Error('provider failed')) }) it('preserves a Host Context policy rejection for the active RPC adapter', async () => { const { ctx } = await setup() - const rejection = new TypertLookupFailure({ code: 'agent-busy', message: 'owned', details: { reason: 'subagent' } }) + const rejection = new RemoteError('session/agent-busy', 'owned', { reason: 'subagent' }) ctx.typert.contexts.registerHost('gatewayFixture', { ...contextProvider(ctx.extend()), resolve: async () => { throw rejection }, @@ -590,7 +594,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'provider-mismatch') + }), 'gateway/provider-mismatch') await mismatch() ctx.typert.contexts.registerHost('gatewayFixture', { @@ -601,7 +605,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, - }), 'context-not-found') + }), 'gateway/context-not-found') }) it('contains lookup provider failures and missing identities', async () => { @@ -615,7 +619,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'lookup-failed') + }), 'gateway/lookup-failed') expect(failure.cause).toEqual(new Error('lookup failed')) await throwing() @@ -627,7 +631,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'lookup-not-found') + }), 'gateway/lookup-not-found') await missing() ctx.typert.lookups.register('gatewayFixture', { @@ -650,7 +654,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, - }), 'definition-unavailable') + }), 'gateway/definition-unavailable') }) it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => { @@ -665,7 +669,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, - }), 'definition-unavailable') + }), 'gateway/definition-unavailable') }) it('retains the no-downgrade guard across Gateway Service reloads', async () => { @@ -684,7 +688,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, - }), 'definition-unavailable') + }), 'gateway/definition-unavailable') }) it('rejects ambiguous SRC endpoints independently of reflection order', async () => { @@ -696,7 +700,7 @@ describe('TypertGatewayService', () => { namespace: 'shared', method: 'run', args: { value: 'ship' }, - }), 'ambiguous-endpoint') + }), 'gateway/ambiguous-endpoint') expect(error.message).toContain('firstShared, secondShared') }) @@ -714,7 +718,7 @@ describe('TypertGatewayService', () => { namespace: testCase.namespace, method: 'run', args: testCase.args, - }), 'signature-invalid') + }), 'gateway/signature-invalid') } }) @@ -728,7 +732,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'signature-invalid') + }), 'gateway/signature-invalid') }) it('requires exact wire fields before invoking business code', async () => { @@ -739,17 +743,17 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { request: { title: 'ship' } }, - }), 'arguments-invalid') + }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, - }), 'arguments-invalid') + }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', method: 'create', args: [] as unknown as Record, - }), 'arguments-invalid') + }), 'gateway/arguments-invalid') expect(service.calls).toEqual([]) }) @@ -761,7 +765,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'strictOnly', args: { request: { title: 1 } }, - }), 'input-invalid') + }), 'gateway/input-invalid') service.nextResult = { title: 1 } await expect(ctx.typertGateway.invoke({ @@ -799,7 +803,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value }, - }), 'input-invalid') + }), 'gateway/input-invalid') }) it('admits an omitted SRC field and hands the Host method undefined', async () => { @@ -823,7 +827,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'passthrough', args: { value: cyclic }, - }), 'input-invalid') + }), 'gateway/input-invalid') const result = new Date(0) service.nextResult = result @@ -855,7 +859,7 @@ describe('TypertGatewayService', () => { for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { await expectCode(ctx.typertGateway.invoke({ namespace: 'goals', method: 'passthrough', args: { value }, - }), 'input-invalid') + }), 'gateway/input-invalid') } }) @@ -871,7 +875,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, - }), 'provider-mismatch') + }), 'gateway/provider-mismatch') }) it('validates binding identity and active method availability', async () => { @@ -881,7 +885,7 @@ describe('TypertGatewayService', () => { namespace: 'wrong-binding', method: 'run', args: { value: 'ship' }, - }), 'binding-invalid') + }), 'gateway/binding-invalid') await ctx.plugin(GoalService) registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }]) @@ -889,7 +893,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'missing', args: { value: 'ship' }, - }), 'method-unavailable') + }), 'gateway/method-unavailable') }) it('requires a visible binding and supports explicitly provided plain Services', async () => { @@ -904,7 +908,7 @@ describe('TypertGatewayService', () => { }]) await expectCode(ctx.typertGateway.invoke({ namespace: 'no-binding', method: 'run', args: { value: 'ship' }, - }), 'binding-invalid') + }), 'gateway/binding-invalid') const plain: { typertRemote?: ReturnType @@ -941,7 +945,7 @@ describe('TypertGatewayService', () => { try { await expectCode(ctx.typertGateway.invoke({ namespace: 'missing-method', method: 'run', args: { value: 'ship' }, - }), 'method-unavailable') + }), 'gateway/method-unavailable') } finally { Object.defineProperty(MissingMethodService.prototype, 'run', descriptor) } @@ -965,7 +969,7 @@ describe('TypertGatewayService', () => { namespace: 'goals', method: 'absent', args: {}, - }), 'invocation-unavailable') + }), 'gateway/invocation-unavailable') }) it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => { @@ -1002,7 +1006,7 @@ describe('TypertGatewayService', () => { const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) @@ -1018,13 +1022,13 @@ describe('TypertGatewayService', () => { for (const endpoint of ['goals', '/create', 'goals/', 'goals/create/extra']) { const result = await handler(endpoint, { args: {} }, signal) - expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) if (result.ok) throw new Error('invalid Remote endpoint unexpectedly succeeded') expect(result.error.message).toContain('invalid Remote endpoint') } for (const payload of [null, [], { args: {}, extra: true }, { only: true }, { args: null }, { args: [] }]) { const result = await handler('goals/create', payload, signal) - expect(result).toMatchObject({ ok: false, error: { code: 'internal' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) if (result.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(result.error.message).toContain('plain-object args field') } @@ -1036,7 +1040,7 @@ describe('TypertGatewayService', () => { new AbortController().signal, )).resolves.toEqual({ ok: false, - error: { code: 'internal', message: 'non-error failure', details: {} }, + error: { code: 'gateway/internal', message: 'non-error failure', details: {} }, }) // A business rejection observed while the carrier signal is already aborted @@ -1051,7 +1055,7 @@ describe('TypertGatewayService', () => { )).resolves.toEqual({ ok: false, error: { - code: 'cancelled', + code: 'gateway/cancelled', message: 'Remote invocation "goals/fail" was aborted', details: {}, }, @@ -1075,7 +1079,7 @@ describe('TypertGatewayService', () => { args: { clientId: 'missing-client', eventId: 'missing', outcome: { kind: 'next' } }, } const inactive = await handler('$events/result', result, new AbortController().signal) - expect(inactive).toMatchObject({ ok: false, error: { code: 'internal' } }) + expect(inactive).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) if (inactive.ok) throw new Error('inactive Remote event result unexpectedly succeeded') expect(inactive.error.message).toContain('identifies no active event stream') @@ -1098,7 +1102,7 @@ describe('TypertGatewayService', () => { for (const payload of [null, [], {}, { other: {} }]) { const invalid = await handler('$events/result', payload, carrier.signal) - expect(invalid).toMatchObject({ ok: false, error: { code: 'internal' } }) + expect(invalid).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) if (invalid.ok) throw new Error('invalid Remote event result payload unexpectedly succeeded') expect(invalid.error.message).toContain('requires exactly one plain-object args field') } @@ -1122,13 +1126,13 @@ describe('TypertGatewayService', () => { await ctx.plugin(GoalService) registerStrict(ctx, [createDescriptor()]) const failure = { - code: 'agent-busy', + code: 'session/agent-busy', message: 'session is owned by subagent routing', details: { reason: 'use subagent delivery for this child session' }, } ctx.typert.lookups.register('gatewayFixture', { ...agentLookup({ id: 'agent-1' }), - resolve: () => { throw new TypertLookupFailure(failure) }, + resolve: () => { throw new RemoteError('session/agent-busy', failure.message, failure.details) }, }) const handler = rawConnection(ctx).handler if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') @@ -1225,7 +1229,7 @@ describe('TypertGatewayService', () => { rpcId: 'rpc-invalid', result: { ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }, }) expect(JSON.stringify(invalidBody)).toContain('plain-object args field') @@ -1249,7 +1253,7 @@ describe('TypertGatewayService', () => { rpcId: 'rpc-withdrawn', result: { ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/definition-unavailable' }, }, }) expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn') diff --git a/packages/api/gateway/tsconfig.client.json b/packages/api/gateway/tsconfig.client.json index 31df1266af..1fa6ae97c0 100644 --- a/packages/api/gateway/tsconfig.client.json +++ b/packages/api/gateway/tsconfig.client.json @@ -12,6 +12,7 @@ "src/client/remote-stream.ts", "src/client/snapshot-stream.ts", "src/client/stream-client.ts", + "src/remote-error-codes.ts", "src/stream-protocol.ts" ], "references": [ diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 54d5f964f3..8a3fc71910 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -8,6 +8,7 @@ "files": [ "src/index.ts", "src/invariant.ts", + "src/remote-error-codes.ts", "src/stream-protocol.ts", "src/stream-server.ts", "src/types.ts" diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index d449efe704..43821e10b9 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -56,7 +56,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' export type { ConnectionHandle, ConnectionSinks, ContentBlock, MessageId, - RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, + RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, StreamChunk, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' @@ -112,7 +112,7 @@ export type { } from '@deepseek-ai/dsh-settings/types' // Provider registry and discovery vocabulary for the llm namespace. export type { - LlmConfigurableProvider, LlmDiscoveredModel, LlmModelDiscoveryError, + LlmConfigurableProvider, LlmDiscoveredModel, LlmModelDiscoveryRequest, LlmProviderInfo, } from '@deepseek-ai/dsh-llm/types' // Reference-discovery result vocabulary for the fileReferences and @@ -120,21 +120,14 @@ export type { export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' -/** Failure vocabulary exposed by the assembled Client data layer. */ -export type ClientFailure = - | import('@deepseek-ai/dsh-client-connection/client').RpcError - | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError - | import('@deepseek-ai/dsh-api-session-controller/types').SessionError - | import('@deepseek-ai/dsh-api-settings-controller/types').CredentialError - | import('@deepseek-ai/dsh-api-settings-controller/types').SettingsError - | import('@deepseek-ai/dsh-llm/types').LlmModelDiscoveryError - | import('@deepseek-ai/dsh-subagent/client').SubagentControlError - | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError - -/** Success or failure returned by Client operations spanning both API families. */ -export type ClientResult = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: ClientFailure } +// The Remote failure vocabulary, re-exported so business packages keep naming +// this assembly alone. Types only: a value export would make spec imports load +// this module's owner /remote artifacts; specs take RemoteError from +// dsh-client-test-runtime instead. +export type { + RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure, RemoteResult, +} from '@deepseek-ai/dsh-typert-protocol' +export type { RemoteHostFacts } from '@deepseek-ai/dsh-api-gateway/client' declare module '@deepseek-ai/cordis' { interface Context { diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 0cdd53a2a3..3cab420545 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -99,6 +99,7 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", @@ -137,6 +138,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index f96c66b464..c17929af5c 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -11,9 +11,9 @@ import type {} from '@deepseek-ai/dsh-agent-presets' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' -import type { ModelSelection, SessionError } from './types.ts' +import type { ModelSelection } from './types.ts' /** Cold Session identity absent from persistence. */ export class ApiSessionNotFound extends Error {} @@ -57,10 +57,7 @@ export class ApiSessionPresetConflict extends Error { } /** Failures produced while resolving one ordinary Session identity to its live Agent. */ -export type ApiSessionAgentError = Extract< - SessionError, - { readonly code: 'session-not-found' | 'agent-busy' | 'internal' } -> +export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'gateway/internal'> /** Result of resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentResult = @@ -97,11 +94,11 @@ export function hasApiSessionSubagentOwner( * @returns a stable Session-domain failure. */ export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError { - return { - code: 'agent-busy', - message: `session "${sessionId}" is owned by subagent routing`, - details: { reason: 'use subagent delivery for this child session' }, - } + return new RemoteError( + 'session/agent-busy', + `session "${sessionId}" is owned by subagent routing`, + { reason: 'use subagent delivery for this child session' }, + ) } /** @@ -145,17 +142,17 @@ export class ApiSessionAgentController { constructor(private readonly ctx: Context) { ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) - if ('error' in found) throw new TypertLookupFailure(found.error) + if ('error' in found) throw found.error return found.agent }) ctx.typert.lookups.configure('session', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) - if ('error' in found) throw new TypertLookupFailure(found.error) + if ('error' in found) throw found.error return found.agent.session }) ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) - if ('error' in found) throw new TypertLookupFailure(found.error) + if ('error' in found) throw found.error return found.agent.ctx }) } @@ -198,13 +195,7 @@ export class ApiSessionAgentController { return { agent: await resume } } catch (error: unknown) { if (error instanceof ApiSessionNotFound) { - return { - error: { - code: 'session-not-found', - message: error.message, - details: { sessionId }, - }, - } + return { error: new RemoteError('session/not-found', error.message, { sessionId }) } } if (error instanceof ApiSessionSubagentOwnership) { return { error: apiSessionSubagentOwnershipError(error.sessionId) } @@ -216,11 +207,11 @@ export class ApiSessionAgentController { return { error: apiSessionSubagentOwnershipError(sessionId) } } return { - error: { - code: 'internal', - message: `resume failed for session "${sessionId}": ${String(error)}`, - details: {}, - }, + error: new RemoteError( + 'gateway/internal', + `resume failed for session "${sessionId}": ${String(error)}`, + {}, + ), } } } diff --git a/packages/api/session-controller/src/client/contract/result.ts b/packages/api/session-controller/src/client/contract/result.ts deleted file mode 100644 index 5306e611de..0000000000 --- a/packages/api/session-controller/src/client/contract/result.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Client operation results spanning the Session and subagent Remote calls. */ - -import type { RpcError } from '@deepseek-ai/dsh-client-connection/client' -import type { SubagentControlError } from '@deepseek-ai/dsh-subagent/client' -import type { SessionError } from '../../types.ts' - -/** Failure surfaced by the Client Session object layer. */ -export type ClientFailure = RpcError | SessionError | SubagentControlError - -/** Success or failure returned by a Client Session operation. */ -export type ClientResult = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: ClientFailure } - -/** - * Fold a rejected carrier operation into the Client Session failure vocabulary. - * @param error - rejection from a Remote or local carrier call. - * @returns the failure branch of a Client Session result. - */ -export function transportResult(error: unknown): ClientResult { - return { - ok: false, - error: { - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - }, - } -} diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 9b8ed3f7ec..7810249c46 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -13,7 +13,6 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' -import type { ClientResult } from './result.ts' import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' /** @@ -84,7 +83,7 @@ export interface ISession { mode: 'queue' | 'steer', signal?: AbortSignal, requestId?: SessionRequestId, - ): Promise> + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. @@ -92,27 +91,27 @@ export interface ISession { */ readAttachment( attachmentId: AttachmentIdType, - ): Promise> + ): Promise> /** * Apply one edit, remove, or strict steer action to a still-pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. * @param action - requested queue operation. * @returns acceptance, or a business/transport error. */ - updateQueue(itemId: MessageId, action: QueueAction): Promise> + updateQueue(itemId: MessageId, action: QueueAction): Promise> /** * Cancel the running turn. Pending queued work remains and resumes in FIFO * order after the Host reaches cancellation quiescence. * @returns acceptance, or the business error. */ - cancel(): Promise> + cancel(): Promise> /** * Rename this session (explicit user title; pins it against automatic * regeneration). * @param title - raw title text (the host normalizes acceptance). * @returns the normalized accepted title and its event seq, or the business error. */ - rename(title: string): Promise> + rename(title: string): Promise> /** * Extend the history window backwards (older messages pagination). * @returns completion; failures land in snapshot.openState/loadingOlder. diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 7c8a90e662..08c13a208e 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -8,10 +8,10 @@ import type { Context } from '@deepseek-ai/cordis' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { AgentContext } from '../scope.ts' import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { SessionBinding, SessionListState } from '../sessions/service.ts' -import type { ClientResult } from './result.ts' import type { SessionFace } from './session.ts' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -83,7 +83,7 @@ export interface ISessions { search( query: string, signal: AbortSignal, - ): Promise> + ): Promise> /** * Fork a session from a completed-turn prefix of the source; on resolution * the child is in the list store and `open()` can target it. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 8aacc0d5d6..b0e12a27b8 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -3,8 +3,8 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRequestId } from '../../types.ts' -import type { ClientFailure } from './result.ts' /** One transient inbox occurrence from the authoritative queue snapshot. */ export interface QueuedMessage { @@ -53,7 +53,7 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error' /** Send/stop failure surfaced by Session consumers. */ export interface PromptError { readonly op: 'send' | 'stop' - readonly error: ClientFailure + readonly error: RemoteFailure } /** Immutable Session lifecycle and control snapshot. */ @@ -70,7 +70,7 @@ export interface SessionSnapshot { } | null readonly removed: boolean readonly openState: OpenState - readonly openError: ClientFailure | null + readonly openError: RemoteFailure | null readonly hasMore: boolean readonly loadingOlder: boolean readonly promptError: PromptError | null diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 04b7cbb553..1095ad26fc 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -2,7 +2,6 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { createSessionControlStream } from './transport.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' @@ -13,7 +12,6 @@ export { SessionEventStream, SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, - sessionStreamFailure, } from './transport.ts' export type { ClientSessionPageRequest, @@ -66,7 +64,6 @@ export type { QueuedMessage, SessionSnapshot, } from './contract/snapshot.ts' -export type { ClientFailure, ClientResult } from './contract/result.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -75,9 +72,8 @@ declare module '@deepseek-ai/cordis' { } } -/** Required wire, Remote, and Context projection services. */ +/** Required Remote and Context projection services. */ export const inject = [ - 'connection', 'typert', 'remote', 'remote.commands', @@ -90,7 +86,6 @@ export const inject = [ * @param ctx - Client Cordis context. */ export function apply(ctx: Context): void { - const connection = ctx.get('connection') as ConnectionHandle const remotes = ctx.remote as unknown as SessionRemotes const sessions = new ClientSessions(ctx, remotes) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) @@ -111,7 +106,7 @@ export function apply(ctx: Context): void { }) control.start() ctx.on('connection/reset', () => { sessions.handleConnected() }) - if (connection.generation.getSnapshot() !== undefined) sessions.handleConnected() + if (ctx.remote.$host.home !== undefined) sessions.handleConnected() ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), resolve: sessionId => sessions.resolveAgentScope(sessionId), diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 9ad6d88f96..47392c86f3 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -9,13 +9,12 @@ import type { SessionControlBaseline, SessionControlFrame, SessionQueuedItem, - SessionError, SessionSummary, SessionJob as JobView, } from '../../types.ts' import { mergeOrderedBaseline } from '../ordered-baseline.ts' -import type { ClientFailure, ClientResult } from '../contract/result.ts' -import { transportResult } from '../contract/result.ts' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' // Type-only merge edge: the title domain's client-namespace outlet declares @@ -51,7 +50,7 @@ export interface SessionListSnapshot { state: 'idle' | 'loading' | 'error' /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ phase: SessionListPhase - error: ClientFailure | null + error: RemoteFailure | null subagentsByParent: Readonly> /** Background jobs per session; an absent key is an empty set. */ jobsBySession: Readonly> @@ -63,7 +62,7 @@ export type SubagentCatalogSnapshot = Omit & /** Absent until the first successful catalog read. */ readonly parentAvailable?: boolean state: 'loading' | 'ready' | 'error' - error: ClientFailure | null + error: RemoteFailure | null } function catalogAvailability(parentAvailable: boolean | undefined): { @@ -112,7 +111,7 @@ export class SessionManager { private listState: 'idle' | 'loading' | 'error' = 'idle' /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */ private listPhase: SessionListPhase = 'pending' - private listError: ClientFailure | null = null + private listError: RemoteFailure | null = null private listInflight: Promise | null = null /** Mutations arriving after a list request starts are replayed over its response. */ private listMutations: SessionListMutation[] | null = null @@ -366,7 +365,7 @@ export class SessionManager { this.notifier.markDirty() const operation = (async () => { try { - const result = toSessionResult(await this.remote.subagents.list(parentSessionId)) + const result = await this.remote.subagents.list(parentSessionId) if (result.ok) { const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride ?? result.value.parentAvailable @@ -395,7 +394,7 @@ export class SessionManager { }) } } catch (error: unknown) { - const folded = transportResult(error) + if (!isRemoteFailure(error)) throw error this.catalogs.set(parentSessionId, { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, @@ -405,7 +404,7 @@ export class SessionManager { ?? previous?.parentAvailable, ), state: 'error', - error: folded.ok ? null : folded.error, + error, }) } finally { this.catalogInflight.delete(parentSessionId) @@ -457,7 +456,7 @@ export class SessionManager { this.notifier.markDirty() this.listInflight = (async () => { try { - const result = toSessionResult(await this.remote.session.list({})) + const result = await this.remote.session.list({}) if (result.ok) { const baseline: SessionSummary[] = this.listPhase === 'pending' ? [...result.value.items] @@ -506,10 +505,9 @@ export class SessionManager { this.listError = result.error } } catch (error) { + if (!isRemoteFailure(error)) throw error this.listState = 'error' - const folded = transportResult(error) - /* v8 ignore next -- the `? null` arm is unreachable: transportResult always returns ok:false. */ - this.listError = folded.ok ? null : folded.error + this.listError = error } finally { this.listMutations = null this.listInflight = null @@ -529,19 +527,15 @@ export class SessionManager { async search( query: string, signal: AbortSignal, - ): Promise> { - try { - const result = toSessionResult(await this.remote.session.search({ query }, signal)) - if (!result.ok) return result - return { - ok: true, - value: { - items: [...result.value.items], - hasMore: result.value.hasMore, - }, - } - } catch (error: unknown) { - return transportResult(error) + ): Promise> { + const result = await this.remote.session.search({ query }, signal) + if (!result.ok) return result + return { + ok: true, + value: { + items: [...result.value.items], + hasMore: result.value.hasMore, + }, } } @@ -558,36 +552,32 @@ export class SessionManager { cwd?: string sessionId?: SessionId } = {}, - ): Promise> { - try { - const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } - const payload = opts.workspaceId !== undefined - ? { workspaceId: opts.workspaceId, ...shared } - : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } - const result = toSessionResult(await this.remote.session.create(payload)) - if (result.ok) { + ): Promise> { + const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } + const payload = opts.workspaceId !== undefined + ? { workspaceId: opts.workspaceId, ...shared } + : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } + const result = await this.remote.session.create(payload) + if (result.ok) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, + ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + } }) + } else { + const publishedSessionId = workspaceAttachSessionId(result.error) + // Publication precedes attachment. The error's id is a real Session, + // so expose it immediately as Ungrouped while the caller keeps the + // prompt buffer and decides whether to retry attachment. + if (publishedSessionId !== undefined) { this.recordMutation({ kind: 'upsert', summary: { - sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, - ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + sessionId: publishedSessionId, + updatedAt: Date.now(), + running: false, + blank: true, } }) - } else { - const publishedSessionId = workspaceAttachSessionId(result.error) - // Publication precedes attachment. The error's id is a real Session, - // so expose it immediately as Ungrouped while the caller keeps the - // prompt buffer and decides whether to retry attachment. - if (publishedSessionId !== undefined) { - this.recordMutation({ kind: 'upsert', summary: { - sessionId: publishedSessionId, - updatedAt: Date.now(), - running: false, - blank: true, - } }) - } } - return result - } catch (error) { - return transportResult(error) } + return result } /** @@ -601,27 +591,23 @@ export class SessionManager { */ async fork( opts: { sessionId: SessionId; atSeq?: number }, - ): Promise> { - try { - const source = this.summaries.find(s => s.sessionId === opts.sessionId) - const result = toSessionResult(await this.remote.session.fork({ - sessionId: opts.sessionId, - ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, - })) - const childId = result.ok - ? result.value.sessionId - : workspaceAttachSessionId(result.error) - if (childId !== undefined) { - this.recordMutation({ kind: 'upsert', summary: { - sessionId: childId, updatedAt: Date.now(), running: false, blank: false, - parentSessionId: opts.sessionId, - ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), - } }) - } - return result - } catch (error) { - return transportResult(error) + ): Promise> { + const source = this.summaries.find(s => s.sessionId === opts.sessionId) + const result = await this.remote.session.fork({ + sessionId: opts.sessionId, + ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, + }) + const childId = result.ok + ? result.value.sessionId + : workspaceAttachSessionId(result.error) + if (childId !== undefined) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: childId, updatedAt: Date.now(), running: false, blank: false, + parentSessionId: opts.sessionId, + ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), + } }) } + return result } /** @@ -1010,13 +996,6 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi } /** Temporary source-plane bridge while the Host contract and client project build independently. */ -function workspaceAttachSessionId(error: ClientFailure): SessionId | undefined { - return error.code === 'workspace-attach-failed' ? error.details.sessionId : undefined -} - -/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ -function toSessionResult( - result: import('@deepseek-ai/dsh-typert-protocol').RemoteResult, -): ClientResult { - return result.ok ? result : { ok: false, error: result.error as SessionError } +function workspaceAttachSessionId(error: RemoteFailure): SessionId | undefined { + return error.code === 'session/workspace-attach-failed' ? error.details.sessionId : undefined } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index a54d1ece0d..3e6c8bf6e0 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -25,7 +25,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' -import type { ClientFailure, ClientResult } from '../contract/result.ts' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionEventSource } from '../contract/events.ts' import type { SessionFace } from '../contract/session.ts' import type { AgentContext, ISessions } from '../contract/sessions.ts' @@ -101,7 +101,7 @@ export class SessionCreateError extends Error { * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. */ constructor( - readonly rpcError: ClientFailure, + readonly rpcError: RemoteFailure, readonly requestedSessionId: SessionId | undefined, ) { super(`session create failed: ${rpcError.code}: ${rpcError.message}`) @@ -117,7 +117,7 @@ export class SessionForkError extends Error { * @param sourceSessionId - the session the fork was cut from. */ constructor( - readonly rpcError: ClientFailure, + readonly rpcError: RemoteFailure, readonly sourceSessionId: SessionId, ) { super(`session fork failed: ${rpcError.code}: ${rpcError.message}`) @@ -335,7 +335,7 @@ export class ClientSessions implements ISessions { search( query: string, signal: AbortSignal, - ): Promise> { + ): Promise> { return this.manager.search(query, signal) } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 815cf89c40..8af47b5aad 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -6,10 +6,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { - SessionEventStream, - sessionStreamFailure, -} from '../transport.ts' +import { SessionEventStream } from '../transport.ts' import type { SessionJournalChange } from '../transport.ts' import type { PromptContentPart, @@ -18,10 +15,7 @@ import type { SessionControlFrame, SessionQueuedItem, SessionRequestId, - SessionError, } from '../../types.ts' -import type { ClientFailure, ClientResult } from '../contract/result.ts' -import { transportResult } from '../contract/result.ts' import type { BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, } from '../contract/session.ts' @@ -33,7 +27,8 @@ import type { SessionEventLikeEntry, SessionLiveEventEntry, } from '../contract/events.ts' import { Notifier } from './notifier.ts' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' @@ -77,7 +72,7 @@ export class Session implements SessionFace { private baseSeq = 0 private hasMore = false private openState: OpenState = 'cold' - private openError: ClientFailure | null = null + private openError: RemoteFailure | null = null private openPromise: Promise | null = null /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale * passes drop all writes once the generation moves on. */ @@ -214,7 +209,7 @@ export class Session implements SessionFace { mode: 'queue' | 'steer', signal?: AbortSignal, requestId?: SessionRequestId, - ): Promise> { + ): Promise> { this.promptError = null this.lastAgentError = null // Synchronous, before the first await: the blank → engaging edge must be @@ -223,52 +218,26 @@ export class Session implements SessionFace { this.promptAttempted = true if (this.blankBit) this.firstPromptPendingTurn = true this.notifier.markDirty() - let result: ClientResult<{ accepted: true }> - try { - if (this.address === undefined) { - const clientTimeZone = resolvedClientTimeZone() - result = toSessionResult(await this.remote.session.prompt({ - requestId: requestId ?? randomUUID() as SessionRequestId, - sessionId: this.sessionId, - mode, - content, - clientTimeZone, - }, signal)) - } else if (this.address.mode === 'one-shot') { - result = { - ok: false, - error: { - code: 'subagent-not-resumable', - message: 'one-shot subagent conversations are read-only', - details: { childSessionId: this.address.childSessionId }, - }, - } - } else { - if (content.some(part => part.type === 'image')) { - result = { - ok: false, - error: { - code: 'attachment-error', - message: 'Image input is unavailable for subagent continuations.', - details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, - }, - } - } else { - const routed = toSessionResult(await this.remote.subagents.prompt({ - requestId: randomUUID() as SessionRequestId, - parentSessionId: this.address.parentSessionId, - childSessionId: this.address.childSessionId, - mode: this.address.mode, - content: content.flatMap(part => part.type === 'text' - ? [{ type: 'text' as const, text: part.text }] - : []), - clientTimeZone: resolvedClientTimeZone(), - }, signal)) - result = routed.ok ? { ok: true, value: { accepted: true } } : routed - } - } - } catch (error) { - result = transportResult(error) + let result: RemoteResult<{ accepted: true }> + if (this.address === undefined) { + const clientTimeZone = resolvedClientTimeZone() + result = await this.remote.session.prompt({ + requestId: requestId ?? randomUUID() as SessionRequestId, + sessionId: this.sessionId, + mode, + content, + clientTimeZone, + }, signal) + } else { + const routed = await this.remote.subagents.prompt({ + requestId: randomUUID() as SessionRequestId, + parentSessionId: this.address.parentSessionId, + childSessionId: this.address.childSessionId, + mode: 'continuable', + content, + clientTimeZone: resolvedClientTimeZone(), + }, signal) + result = routed.ok ? { ok: true, value: { accepted: true } } : routed } if (!result.ok) { if (requestId !== undefined) this.retireFailedSubmission(requestId) @@ -299,66 +268,38 @@ export class Session implements SessionFace { */ async readAttachment( attachmentId: AttachmentIdType, - ): Promise> { - try { - const result = await this.remote.session.attachment({ - sessionId: this.sessionId, - attachmentId, - }) - if (!result.ok) return toSessionResult(result) - const binary = atob(result.value.data) - const data = Uint8Array.from(binary, char => char.charCodeAt(0)) - return { ok: true, value: { attachment: result.value.attachment, data } } - } catch (error) { - return transportResult(error) - } + ): Promise> { + const result = await this.remote.session.attachment({ + sessionId: this.sessionId, + attachmentId, + }) + if (!result.ok) return result + const binary = atob(result.value.data) + const data = Uint8Array.from(binary, char => char.charCodeAt(0)) + return { ok: true, value: { attachment: result.value.attachment, data } } } /** Apply one operation to a still-pending queue occurrence. */ - async updateQueue(itemId: MessageId, action: QueueAction): Promise> { - try { - return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action })) - } catch (error) { - return transportResult(error) - } + async updateQueue(itemId: MessageId, action: QueueAction): Promise> { + return this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }) } /** * Stop the active turn while the Host preserves pending inbox work; failures - * land in promptError (same error-strip display slot). A continuable - * subagent address routes through `subagents.interruptByParent`, whose durable - * parent-address authority works without a live parent Agent; a one-shot - * address stays uncancellable (the UI offers no stop action, so this arm is - * defensive). + * land in promptError (same error-strip display slot). A subagent address + * routes through `subagents.interruptByParent`, whose durable parent-address + * authority works without a live parent Agent. * @returns the cancel result. */ - async cancel(): Promise> { + async cancel(): Promise> { const address = this.address - if (address !== undefined && address.mode === 'one-shot') { - const result: ClientResult<{ accepted: true }> = { - ok: false, - error: { - code: 'subagent-delivery-unavailable', - message: 'subagent activation cancellation is unavailable', - details: { childSessionId: address.childSessionId }, - }, - } - this.promptError = { op: 'stop', error: result.error } - this.notifier.markDirty() - return result - } - let result: ClientResult<{ accepted: true }> - try { - result = address !== undefined - ? toSessionResult(await this.remote.subagents.interruptByParent( - address.childSessionId, - address.parentSessionId, - address.mode, - )) - : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId })) - } catch (error) { - result = transportResult(error) - } + const result = address !== undefined + ? await this.remote.subagents.interruptByParent( + address.childSessionId, + address.parentSessionId, + 'continuable', + ) + : await this.remote.session.cancel({ sessionId: this.sessionId }) if (!result.ok) { this.promptError = { op: 'stop', error: result.error } this.notifier.markDirty() @@ -375,14 +316,10 @@ export class Session implements SessionFace { * @param title - raw title text (the host normalizes acceptance). * @returns the rename result (normalized accepted title + title event seq). */ - async rename(title: string): Promise> { - try { - const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title })) - if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) - return result - } catch (error) { - return transportResult(error) - } + async rename(title: string): Promise> { + const result = await this.remote.session.rename({ sessionId: this.sessionId, title }) + if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) + return result } /** @@ -390,7 +327,7 @@ export class Session implements SessionFace { * admission semantics (the host executor durably logs the lifecycle; * outcomes render as flow nodes, never as a response echo). * @param line - the full command line, leading slash included. - * @returns the admission result, or the error branch on transport failure. + * @returns the admission result. */ async command(line: string): Promise> { const result = await this.remote.commands.execute(this.sessionId, line, []) @@ -420,7 +357,7 @@ export class Session implements SessionFace { try { await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES }) } catch (error) { - if (sessionStreamFailure(error) === undefined) { + if (!isRemoteFailure(error)) { console.error('[session-controller] loadOlder failed:', error) } } finally { @@ -600,9 +537,10 @@ export class Session implements SessionFace { this.openState = 'open' } catch (error) { if (generation !== this.openGeneration || this.events !== events) return + if (!isRemoteFailure(error)) throw error this.events = undefined this.openState = 'error' - this.openError = openFailure(error) + this.openError = error } finally { if (generation === this.openGeneration) this.notifier.markDirty() } @@ -714,11 +652,12 @@ export class Session implements SessionFace { /** Publish a terminal background failure only while this stream still owns the Session. */ private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { if (generation !== this.openGeneration || this.events !== events) return + if (!isRemoteFailure(error)) throw error this.openGeneration++ this.events = undefined this.openPromise = null this.openState = 'error' - this.openError = openFailure(error) + this.openError = error void events.dispose() this.notifier.markDirty() } @@ -774,17 +713,3 @@ function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { } return refs } - -/** Convert a terminal Session stream failure to the Client error vocabulary. */ -function openFailure(error: unknown): ClientFailure { - const failure = sessionStreamFailure(error) - if (failure !== undefined) return failure as SessionError - const folded = transportResult(error) - /* v8 ignore next -- transportResult never returns an ok result. */ - if (folded.ok) throw new Error('transportResult returned an unexpected success') - return folded.error -} -/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ -function toSessionResult(result: RemoteResult): ClientResult { - return result.ok ? result : { ok: false, error: result.error as SessionError } -} diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index 48298268bb..69a9b934e1 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -1,12 +1,10 @@ /** Session-specific adapters for Gateway-owned Remote stream lifecycles. */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote' -import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import { RemoteJournalStream, RemoteSnapshotStream, RemoteStreamCarrierError, - RemoteStreamError, type ClientRemote, type RemoteJournalChange, type RemoteJournalFrame, @@ -25,6 +23,7 @@ import { historyRecordLastSeq, } from './sessions/history-records.ts' import type { SessionEventLikeEntry, SessionLiveEventEntry } from './contract/events.ts' +import type { SessionRemotes } from './sessions/remotes.ts' export { SESSION_SEARCH_RESULT_LIMIT, @@ -80,8 +79,6 @@ export type SessionControlStream = RemoteSnapshotStream< SessionControlDeltaFrame > -type SessionStreamRemote = Pick - /** Domain sinks used by the Host-wide Session control stream. */ export interface SessionControlStreamOptions { /** Apply a complete baseline or one later update. */ @@ -109,7 +106,7 @@ export interface SessionEventStreamOptions { * @returns an unstarted stream owned by the Client Session runtime. */ export function createSessionControlStream( - remote: SessionStreamRemote, + remote: SessionRemotes, options: SessionControlStreamOptions, ): SessionControlStream { const stream = remote.$stream({ @@ -142,7 +139,7 @@ export class SessionEventStream extends RemoteJournalStream< * @param options - Session event-window destinations. */ constructor( - private readonly remote: SessionStreamRemote, + private readonly remote: SessionRemotes, private readonly address: SessionAddress, options: SessionEventStreamOptions, ) { @@ -198,13 +195,7 @@ export class SessionEventStream extends RemoteJournalStream< { address: this.address, throughSeq, ...request }, signal, ) - if (!result.ok) { - throw new RemoteStreamError( - result.error.code, - result.error.message, - result.error.details, - ) - } + if (!result.ok) throw result.error return result.value } @@ -215,13 +206,3 @@ export class SessionEventStream extends RemoteJournalStream< return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages } } } - -/** - * Recover a Host Session failure from a Remote stream terminal error. - * @param error - value thrown while opening or consuming a Session stream. - * @returns the Host failure, or `undefined` for carrier and local failures. - */ -export function sessionStreamFailure(error: unknown): RemoteFailure | undefined { - if (!(error instanceof RemoteStreamError)) return undefined - return { code: error.code, message: error.message, details: error.details } -} diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 48c41f3aa1..3abefd52fa 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { @@ -14,7 +13,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { Workspace } from '@deepseek-ai/dsh-workspace' import { ApiSessionAgentController, @@ -71,14 +71,14 @@ export class SessionCommandController { */ async create(request: SessionCreateRequest): Promise { if (request.workspaceId !== undefined && request.cwd !== undefined) { - reject('bad-request', 'session.create accepts workspaceId or cwd, not both', {}) + throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {}) } const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`) let workspace: Workspace | undefined if (request.workspaceId !== undefined) { workspace = this.ctx.workspaceRegistry.get(request.workspaceId) if (workspace === undefined) { - reject('workspace-not-found', `workspace "${request.workspaceId}" not found`, { + throw new RemoteError('workspace/not-found', `workspace "${request.workspaceId}" not found`, { workspaceId: request.workspaceId, }) } @@ -99,8 +99,8 @@ export class SessionCommandController { try { await workspace.attachSession(sessionId) } catch (error) { - reject( - 'workspace-attach-failed', + throw new RemoteError( + 'session/workspace-attach-failed', `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, { sessionId, workspaceId: workspace.id }, ) @@ -143,9 +143,9 @@ export class SessionCommandController { } return { selected: { ...selected } } } catch (error) { - if (error instanceof TypertRemoteFailure) throw error - reject( - 'model-unavailable', + if (remoteErrorOf(error) !== undefined) throw error + throw new RemoteError( + 'session/model-unavailable', error instanceof Error ? error.message : String(error), { provider: request.provider, model: request.model }, ) @@ -162,17 +162,17 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) const titles = this.ctx.get('sessionTitle') if (titles === undefined) { - reject('internal', 'renaming is unavailable: this deployment mounts no session-title service', {}) + throw new RemoteError('gateway/internal', 'renaming is unavailable: this deployment mounts no session-title service', {}) } try { const accepted = titles.rename(agent.session, request.title) return { title: accepted.title, seq: accepted.eventSeq } } catch (error) { if (error instanceof SessionTitleInvalidError) { - reject('title-invalid', error.message, { sessionId: request.sessionId }) + throw new RemoteError('session/title-invalid', error.message, { sessionId: request.sessionId }) } - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `failed to rename session "${request.sessionId}": ${String(error)}`, {}, ) @@ -187,7 +187,7 @@ export class SessionCommandController { async fork(request: SessionForkRequest): Promise { if (request.atSeq !== undefined && (!Number.isInteger(request.atSeq) || request.atSeq < 0)) { - reject('bad-request', 'atSeq must be a non-negative integer', {}) + throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative integer', {}) } let observed: SessionObservation try { @@ -195,12 +195,12 @@ export class SessionCommandController { } catch (error) { if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - reject('session-not-found', `session "${request.sessionId}" not found`, { + throw new RemoteError('session/not-found', `session "${request.sessionId}" not found`, { sessionId: request.sessionId, }) } - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `fork source unavailable for session "${request.sessionId}": ${String(error)}`, {}, ) @@ -216,8 +216,8 @@ export class SessionCommandController { ? source.events.findLast(event => event.type === 'turn/end') : undefined) if (boundary === undefined) { - reject( - 'fork-unavailable', + throw new RemoteError( + 'session/fork-unavailable', atSeq !== undefined && atSeq <= lastSeq ? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}` : `session "${request.sessionId}" has no completed turn to fork from`, @@ -230,8 +230,8 @@ export class SessionCommandController { try { workspace = await this.forkWorkspace(source.header) } catch (error) { - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`, {}, ) @@ -255,8 +255,8 @@ export class SessionCommandController { setup: composition.setup, }) } catch (error) { - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `failed to fork session "${request.sessionId}": ${String(error)}`, {}, ) @@ -265,8 +265,8 @@ export class SessionCommandController { try { await workspace.attachSession(childId) } catch (error) { - reject( - 'workspace-attach-failed', + throw new RemoteError( + 'session/workspace-attach-failed', `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`, { sessionId: childId, workspaceId: workspace.id }, ) @@ -285,8 +285,8 @@ export class SessionCommandController { ? undefined : canonicalClientTimeZone(request.clientTimeZone) if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { - reject( - 'invalid-time-zone', + throw new RemoteError( + 'session/invalid-time-zone', 'clientTimeZone must be UTC or a valid IANA Area/Location name', { value: request.clientTimeZone }, ) @@ -294,8 +294,8 @@ export class SessionCommandController { const agent = await this.resolveAgent(request.sessionId) const selection = this.agents.selectionFor(agent).current if (!routeServed(this.ctx, selection.provider)) { - reject( - 'model-unavailable', + throw new RemoteError( + 'session/model-unavailable', `no adapter serves provider "${selection.provider}"; select a model for this session`, { provider: selection.provider, model: selection.model }, ) @@ -312,8 +312,8 @@ export class SessionCommandController { const current = this.agents.selectionFor(agent).current const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model) if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { - reject( - 'attachment-error', + throw new RemoteError( + 'session/attachment-invalid', `Model "${current.model}" does not support image input.`, { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, ) @@ -324,11 +324,11 @@ export class SessionCommandController { if (request.mode === 'steer') agent.steer(message) else agent.followup(message) } catch (error) { - if (error instanceof TypertRemoteFailure) throw error + if (remoteErrorOf(error) !== undefined) throw error if (error instanceof AttachmentError) { - reject('attachment-error', error.message, { reason: error.code }) + throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) } - reject('agent-busy', 'prompt rejected', { reason: String(error) }) + throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) }) } return { accepted: true } } @@ -346,18 +346,18 @@ export class SessionCommandController { source = await this.readSessionState(request.sessionId) } catch (error) { if (error instanceof ApiSessionNotFound) { - reject('session-not-found', error.message, { sessionId: request.sessionId }) + throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }) } - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`, {}, ) } const ref = referencedImage(source.events, String(request.attachmentId)) if (ref === undefined) { - reject( - 'attachment-error', + throw new RemoteError( + 'session/attachment-invalid', 'Image is not referenced by this session.', { reason: 'ATTACHMENT_NOT_REFERENCED' }, ) @@ -370,9 +370,9 @@ export class SessionCommandController { } } catch (error) { if (error instanceof AttachmentError) { - reject('attachment-error', error.message, { reason: error.code }) + throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) } - reject('internal', 'Unable to read image attachment.', {}) + throw new RemoteError('gateway/internal', 'Unable to read image attachment.', {}) } } @@ -384,18 +384,18 @@ export class SessionCommandController { updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { if (request.action.kind === 'edit' && request.action.content.some(block => block.type !== 'text')) { - reject( - 'attachment-error', + throw new RemoteError( + 'session/attachment-invalid', 'queue edits accept text content only', { reason: 'QUEUE_EDIT_NON_TEXT' }, ) } const agent = this.ctx.agents.get(request.sessionId) if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) + throw apiSessionSubagentOwnershipError(request.sessionId) } if (agent === undefined) { - reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) } const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId) const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId) @@ -403,11 +403,11 @@ export class SessionCommandController { ? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep } : { target: 'next-turn' as const, message: nextTurn } if (located === undefined) { - reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) } const { target, message } = located if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) { - reject('steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId }) + throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId }) } if (request.action.kind === 'edit') { agent.inbox.replace(request.itemId, freezeMessage({ @@ -429,14 +429,14 @@ export class SessionCommandController { cancel(request: SessionCancelRequest): SessionCancelValue { const agent = this.ctx.agents.get(request.sessionId) if (agent === undefined) { - reject( - 'session-not-found', + throw new RemoteError( + 'session/not-found', `session "${request.sessionId}" not found (not attached)`, { sessionId: request.sessionId }, ) } if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) + throw apiSessionSubagentOwnershipError(request.sessionId) } agent.cancel({ kind: 'user' }, { keepInbox: true }) return { accepted: true } @@ -444,41 +444,30 @@ export class SessionCommandController { private async resolveAgent(sessionId: SessionId): Promise { const found = await this.agents.resolveAgent(sessionId) - if ('error' in found) rejectFailure(found.error) + if ('error' in found) throw found.error return found.agent } private rejectCreation(sessionId: SessionId, error: unknown): never { + if (remoteErrorOf(error) !== undefined) throw error if (error instanceof ApiSessionPresetConflict) { - reject('agent-preset-conflict', error.message, { + throw new RemoteError('agent-preset/conflict', error.message, { sessionId: error.sessionId, requestedPreset: error.requestedPreset, ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), }) } - if (error instanceof UnknownPresetError) { - reject('agent-preset-not-found', error.message, { - agentPreset: error.presetId, - available: [...error.available], - }) - } - if (error instanceof PresetMountError) { - reject('agent-preset-invalid', error.message, { - agentPreset: error.presetId, - reason: error.reason, - }) - } if (error instanceof ApiSessionCwdConflict) { - reject('session-conflict', error.message, { + throw new RemoteError('session/conflict', error.message, { sessionId: error.sessionId, requestedCwd: error.requestedCwd, ...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }), }) } if (error instanceof ApiSessionSubagentOwnership) { - rejectFailure(apiSessionSubagentOwnershipError(error.sessionId)) + throw apiSessionSubagentOwnershipError(error.sessionId) } - reject('internal', `failed to create session "${sessionId}": ${String(error)}`, {}) + throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {}) } private async readSessionState(sessionId: SessionId): Promise { @@ -503,14 +492,6 @@ export class SessionCommandController { } } -function rejectFailure(error: { readonly code: string; readonly message: string; readonly details: object }): never { - throw new TypertRemoteFailure(error) -} - -function reject(code: string, message: string, details: object): never { - throw new TypertRemoteFailure({ code, message, details }) -} - async function durablePromptContent( ctx: Context, content: readonly SessionPromptRequest['content'][number][], @@ -580,18 +561,6 @@ function referencedImage( return undefined } -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ - -function canonicalClientTimeZone(value: string): string | undefined { - if (value.length === 0 || value.trim() !== value - || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined - try { - return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone - } catch { - return undefined - } -} - function routeServed(ctx: Context, provider: string): boolean { return ctx.llm.listProviders().some(entry => entry.id === provider) } diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index d78509e49b..ce32cbf3d5 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -6,7 +6,7 @@ import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-sessi import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import type {} from '@deepseek-ai/dsh-subagent' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { SessionAddress, SessionChunkRun, @@ -55,15 +55,15 @@ export class SessionHistoryController { const sourceLog = source.events const sourceCursor = sourceLog.at(-1)?.seq ?? -1 if (request.throughSeq > sourceCursor) { - reject( - 'bad-request', + throw new RemoteError( + 'gateway/bad-request', `session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`, {}, ) } /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) { - reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {}) + throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(request.throughSeq)}`, {}) } const page = paginate( sourceLog, @@ -155,7 +155,7 @@ export class SessionHistoryController { } if (item.seq < nextSeq) continue if (item.seq !== nextSeq) { - reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {}) + throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(nextSeq)}`, {}) } nextSeq++ yield entryFor(item) @@ -211,22 +211,22 @@ function projectionBlock( function validatePageRequest(request: SessionPageRequest): void { if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) { - reject('bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) + throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) } if (request.beforeSeq !== undefined && (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) { - reject('bad-request', 'beforeSeq must be a non-negative safe integer', {}) + throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) } if (request.maxMessages !== undefined && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { - reject('bad-request', 'maxMessages must be a positive safe integer', {}) + throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) } } function validateFollowRequest(request: SessionFollowRequest): void { if (request.maxMessages !== undefined && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { - reject('bad-request', 'maxMessages must be a positive safe integer', {}) + throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) } } @@ -241,34 +241,34 @@ function validateAddress( ): void { if (address.kind === 'session') { if (header.origin === 'subagent') { - reject('agent-busy', 'subagent Sessions require their durable parent address', { + throw new RemoteError('session/agent-busy', 'subagent Sessions require their durable parent address', { reason: 'use subagent delivery for this child session', }) } return } if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) { - reject('subagent-unauthorized', 'subagent does not belong to the supplied parent', { + throw new RemoteError('subagent/unauthorized', 'subagent does not belong to the supplied parent', { childSessionId: address.childSessionId, }) } const identity = projections?.values.subagent if (identity === null) { - reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', { + throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is corrupt', { parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, reason: 'corrupt', }) } if (identity === undefined || identity.seq < (header.seedLength ?? 0)) { - reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', { + throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', { parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, reason: 'unsupported', }) } if (identity.mode !== address.mode) { - reject('subagent-unauthorized', 'subagent mode does not match the supplied address', { + throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', { childSessionId: address.childSessionId, }) } @@ -276,18 +276,14 @@ function validateAddress( function rejectNotFound(address: SessionAddress): never { if (address.kind === 'session') { - reject('session-not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId }) + throw new RemoteError('session/not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId }) } - reject('subagent-not-found', 'subagent is unavailable', { + throw new RemoteError('subagent/not-found', 'subagent is unavailable', { parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, }) } -function reject(code: string, message: string, details: object): never { - throw new TypertRemoteFailure({ code, message, details }) -} - function paginate( events: readonly SessionEvent[], beforeSeq: number | undefined, diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 342dd977eb..572f895754 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -6,7 +6,7 @@ import { errorChain } from '@deepseek-ai/dsh-llm' 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' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { ApiSessionAgentController, inspectApiSession, @@ -264,7 +264,7 @@ export class SessionController extends TypertRemoteService { * @param request - path after best-effort Session workspace resolution. * @param signal - caller lifetime; abort terminates the native command. * @returns confirmation after the native opener accepts the path. - * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. + * @throws RemoteError when the request is invalid, cancelled, or the opener fails. */ @Remote('openWorkspacePath') async openWorkspacePath( @@ -272,27 +272,23 @@ export class SessionController extends TypertRemoteService { signal: AbortSignal, ): Promise { if (request.path.length === 0) { - throw new TypertRemoteFailure({ - code: 'bad-request', - message: 'session.openWorkspacePath requires a non-empty path', - details: {}, - }) + throw new RemoteError( + 'gateway/bad-request', + 'session.openWorkspacePath requires a non-empty path', + {}, + ) } signal.throwIfAborted() try { await this.openPath(request.path, signal) return { opened: true } } catch (error: unknown) { - if (signal.aborted) { - throw new TypertRemoteFailure({ - code: 'cancelled', message: 'path open was aborted', details: {}, - }) - } - throw new TypertRemoteFailure({ - code: 'internal', - message: `path open failed: ${error instanceof Error ? error.message : String(error)}`, - details: {}, - }) + if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) + throw new RemoteError( + 'gateway/internal', + `path open failed: ${error instanceof Error ? error.message : String(error)}`, + {}, + ) } } diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index 739a8baf31..ab72da1c92 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -8,7 +8,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek- import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import { SESSION_SEARCH_RESULT_LIMIT, @@ -226,8 +226,8 @@ export class ApiSessionList { signal.throwIfAborted() const provider = this.ctx.get('sessionQuery') if (provider === undefined) { - reject( - 'internal', + throw new RemoteError( + 'gateway/internal', 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query', {}, ) @@ -317,9 +317,9 @@ export class ApiSessionList { } catch (error: unknown) { signal.throwIfAborted() if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') { - reject('cancelled', 'session search was aborted', {}) + throw new RemoteError('gateway/cancelled', 'session search was aborted', {}) } - reject('internal', `session search failed: ${String(error)}`, {}) + throw new RemoteError('gateway/internal', `session search failed: ${String(error)}`, {}) } } @@ -351,25 +351,21 @@ export class ApiSessionList { function normalizeSearchQuery(query: string): string { const normalized = query.trim() if (normalized.length === 0) { - reject('bad-request', 'session search query must not be empty', {}) + throw new RemoteError('gateway/bad-request', 'session search query must not be empty', {}) } if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) { - reject( - 'bad-request', + throw new RemoteError( + 'gateway/bad-request', `session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`, {}, ) } if (normalized.includes('\0')) { - reject('bad-request', 'session search query must not contain NUL', {}) + throw new RemoteError('gateway/bad-request', 'session search query must not contain NUL', {}) } return normalized } -function reject(code: string, message: string, details: object): never { - throw new TypertRemoteFailure({ code, message, details }) -} - function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number { return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0) } diff --git a/packages/api/session-controller/src/skill-catalog.ts b/packages/api/session-controller/src/skill-catalog.ts index 3cd15669ff..82043a4038 100644 --- a/packages/api/session-controller/src/skill-catalog.ts +++ b/packages/api/session-controller/src/skill-catalog.ts @@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError } from '@deepseek-ai/dsh-session-query' import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { ScopeKey } from '@deepseek-ai/dsh-scope' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { SkillListRequest, SkillListValue } from './types.ts' declare module '@deepseek-ai/cordis' { @@ -30,7 +30,7 @@ export class SessionSkillCatalog extends TypertRemoteService { * @param request - Session identity whose cwd and preset select the catalog view. * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. * @returns user-invocable skill metadata without loading skill bodies. - * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + * @throws RemoteError when the Session cannot be inspected or no registry can serve it. */ @Remote async list(request: SkillListRequest, signal: AbortSignal): Promise { @@ -48,19 +48,16 @@ export class SessionSkillCatalog extends TypertRemoteService { } catch (error: unknown) { if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - throw failure( - 'session-not-found', - `session "${sessionId}" not found`, - { sessionId }, - ) + throw new RemoteError('session/not-found', `session "${sessionId}" not found`, { sessionId }) } - throw failure( - 'internal', + throw new RemoteError( + 'gateway/internal', `session "${sessionId}" could not be inspected: ${String(error)}`, + {}, ) } if (cwd === undefined) { - throw failure('internal', `session "${sessionId}" has no project cwd`) + throw new RemoteError('gateway/internal', `session "${sessionId}" has no project cwd`, {}) } const live = this.ctx.agents.get(sessionId) @@ -68,9 +65,10 @@ export class SessionSkillCatalog extends TypertRemoteService { const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') const skillRegistry = scoped ?? this.ctx.get('skills') if (skillRegistry === undefined) { - throw failure( - 'internal', + throw new RemoteError( + 'gateway/internal', 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', + {}, ) } @@ -86,7 +84,7 @@ export class SessionSkillCatalog extends TypertRemoteService { })), } } catch (error: unknown) { - throw failure('internal', `skill listing failed: ${String(error)}`) + throw new RemoteError('gateway/internal', `skill listing failed: ${String(error)}`, {}) } } @@ -108,13 +106,4 @@ export class SessionSkillCatalog extends TypertRemoteService { } } -/** Build one stable Remote failure with optional typed details. */ -function failure( - code: 'session-not-found' | 'internal', - message: string, - details: { readonly sessionId: SessionId } | Record = {}, -): TypertRemoteFailure { - return new TypertRemoteFailure({ code, message, details }) -} - export default SessionSkillCatalog diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 167937e3d3..7359d89004 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -174,55 +174,39 @@ export const SESSION_SEARCH_RESULT_LIMIT = 20 /** Maximum search snippet length in Unicode code points. */ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 -/** Error details returned by Session Remote methods. */ -export interface SessionErrorDetailsMap { - 'bad-request': Record - cancelled: Record - 'session-not-found': { readonly sessionId: SessionId } - 'model-unavailable': { readonly provider: string; readonly model: string } - 'session-conflict': { - readonly sessionId: SessionId - readonly requestedCwd: string - readonly existingCwd?: string +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'session/model-unavailable': { readonly provider: string; readonly model: string } + 'session/conflict': { + readonly sessionId: SessionId + readonly requestedCwd: string + readonly existingCwd?: string + } + 'session/agent-busy': { readonly reason: string } + 'session/invalid-time-zone': { readonly value: string } + 'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string } + 'agent-preset/conflict': { + readonly sessionId: SessionId + readonly requestedPreset: string + readonly existingPreset?: string + } + 'session/attachment-invalid': { readonly reason: string } + 'session/queue-item-not-found': { readonly itemId: MessageId } + 'session/steer-unavailable': { readonly itemId: MessageId } + 'session/title-invalid': { readonly sessionId: SessionId } + 'session/fork-unavailable': { readonly sessionId: SessionId } + 'subagent/not-found': { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + } + 'subagent/catalog-diagnostic': { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } } - 'invalid-time-zone': { readonly value: string } - 'workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string } - 'workspace-not-found': { readonly workspaceId: string } - 'agent-preset-conflict': { - readonly sessionId: SessionId - readonly requestedPreset: string - readonly existingPreset?: string - } - 'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] } - 'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string } - 'agent-busy': { readonly reason: string } - 'attachment-error': { readonly reason: string } - 'queue-item-not-found': { readonly itemId: MessageId } - 'steer-unavailable': { readonly itemId: MessageId } - 'title-invalid': { readonly sessionId: SessionId } - 'fork-unavailable': { readonly sessionId: SessionId } - 'subagent-not-found': { - readonly parentSessionId: SessionId - readonly childSessionId: SessionId - } - 'subagent-catalog-diagnostic': { - readonly parentSessionId: SessionId - readonly childSessionId: SessionId - readonly reason: 'corrupt' | 'unsupported' | 'unavailable' - } - 'subagent-unauthorized': { readonly childSessionId: SessionId } - internal: Record } -/** Session business failure returned without throwing a carrier error. */ -export type SessionError = { - [Code in keyof SessionErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: SessionErrorDetailsMap[Code] - } -}[keyof SessionErrorDetailsMap] - /** Session-addressed request for the human-invocable skill catalog. */ export interface SkillListRequest { readonly sessionId: SessionId diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 3f4e721228..6bfea1609d 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -8,7 +8,6 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -154,7 +153,7 @@ describe('ApiSession Agent lookup and recovery', () => { header: header('observed-without-cwd', null), } as SessionObservation await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({ - error: { code: 'session-not-found' }, + error: { code: 'session/not-found' }, }) }) @@ -170,7 +169,7 @@ describe('ApiSession Agent lookup and recovery', () => { if (host === undefined) throw new Error('Agent Context resolver was not registered') await expect(host.resolve(live.id)).resolves.toBe(live.ctx) - await expect(host.resolve(SessionId('missing'))).rejects.toBeInstanceOf(TypertLookupFailure) + await expect(host.resolve(SessionId('missing'))).rejects.toMatchObject({ code: 'session/not-found' }) }) it('returns raced ordinary Agents and ownership failures after resume throws', async () => { @@ -200,7 +199,7 @@ describe('ApiSession Agent lookup and recovery', () => { throw new Error('raced child publication') }) await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ - error: { code: 'agent-busy' }, + error: { code: 'session/agent-busy' }, }) }) @@ -211,7 +210,7 @@ describe('ApiSession Agent lookup and recovery', () => { inspect: vi.fn(), }) await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({ - error: { code: 'session-not-found' }, + error: { code: 'session/not-found' }, }) const failed = await harness() @@ -222,7 +221,7 @@ describe('ApiSession Agent lookup and recovery', () => { }) vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable')) await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({ - error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string }, + error: { code: 'gateway/internal', message: expect.stringContaining('factory unavailable') as string }, }) }) @@ -408,7 +407,7 @@ describe('ApiSession create or adoption', () => { mount: () => Promise.resolve(), } as never) await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ - error: { code: 'agent-busy' }, + error: { code: 'session/agent-busy' }, }) const conflict = await harness() diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 2ecaff6804..2f74ffe9a1 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -63,12 +63,14 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise { registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), } - ctx.reflect.provide('connection', connection) ctx.reflect.provide('remote', { ...remote, $stream: (options: RemoteStreamOptions) => ( new RemoteStream(connection, options) ), + get $host() { + return { home: generation?.host.home, isLoopback: connection.isLoopback } + }, $on: (event: string, listener: RemoteListener) => { const eventListeners = listeners.get(event) ?? new Set() eventListeners.add(listener) diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index 789fe04d6d..796ba1727c 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import { MutableSessionEventSource, type SessionLiveEventEntry, } from '../src/client/contract/events.ts' -import { transportResult } from '../src/client/contract/result.ts' function entry(seq: number): SessionLiveEventEntry { return { @@ -76,14 +75,4 @@ describe('Client Session contracts', () => { expect(iterate).toHaveBeenCalledOnce() }) - it('folds Error and non-Error carrier rejections into Client failures', () => { - expect(transportResult(new Error('transport unavailable'))).toEqual({ - ok: false, - error: { code: 'internal', message: 'transport unavailable', details: {} }, - }) - expect(transportResult(404)).toEqual({ - ok: false, - error: { code: 'internal', message: '404', details: {} }, - }) - }) }) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index 88b1450970..42bb3fe508 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -1,9 +1,10 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' -import { PresetMountError } from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-presets' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace' import { describe, expect, it, vi } from 'vitest' import { @@ -14,7 +15,7 @@ import { SessionCommandController } from '../src/commands.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' async function expectFailure(operation: Promise, code: string): Promise { - await expect(operation).rejects.toMatchObject({ failure: { code } }) + await expect(operation).rejects.toMatchObject({ code }) } function controllerAgents(overrides: object = {}): ApiSessionAgentController { @@ -76,7 +77,7 @@ describe('Session creation failures', () => { ) await expectFailure(missingController.create({ workspaceId: 'missing' as WorkspaceId, - }), 'workspace-not-found') + }), 'workspace/not-found') await missing.fiber.dispose() const failed = await baseContext() @@ -97,26 +98,30 @@ describe('Session creation failures', () => { await expectFailure(failedController.create({ sessionId: SessionId('workspace-session'), workspaceId: workspace.id, - }), 'workspace-attach-failed') + }), 'session/workspace-attach-failed') await failed.fiber.dispose() }) it.each([ { - error: new PresetMountError('broken', 'invalid composition'), - code: 'agent-preset-invalid', + error: new RemoteError( + 'agent-preset/invalid', + 'agent-presets: preset "broken" failed to mount: invalid composition', + { agentPreset: 'broken', reason: 'invalid composition' }, + ), + code: 'agent-preset/invalid', }, { error: new ApiSessionCwdConflict(SessionId('cwd-less'), '/requested', undefined), - code: 'session-conflict', + code: 'session/conflict', }, { error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'), - code: 'session-conflict', + code: 'session/conflict', }, { error: new Error('factory unavailable'), - code: 'internal', + code: 'gateway/internal', }, ])('maps $code creation failures', async ({ error, code }) => { const ctx = await baseContext() @@ -140,7 +145,7 @@ describe('Session creation failures', () => { await expectFailure(controller.create({ workspaceId: 'workspace-1' as WorkspaceId, cwd: '/workspace', - }), 'bad-request') + }), 'gateway/bad-request') await ctx.fiber.dispose() }) @@ -179,7 +184,7 @@ describe('Session fork failures', () => { ) await expectFailure(unavailableController.fork({ sessionId: SessionId('missing'), - }), 'session-not-found') + }), 'session/not-found') await withoutPersistence.fiber.dispose() const missing = await baseContext() @@ -191,7 +196,7 @@ describe('Session fork failures', () => { const missingController = new SessionCommandController(missing, controllerAgents(), '/default') await expectFailure(missingController.fork({ sessionId: SessionId('missing'), - }), 'session-not-found') + }), 'session/not-found') await missing.fiber.dispose() }) @@ -201,7 +206,7 @@ describe('Session fork failures', () => { vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) const controller = new SessionCommandController(ctx, controllerAgents(), '/default') - await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal') + await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal') await ctx.fiber.dispose() }) @@ -211,7 +216,7 @@ describe('Session fork failures', () => { const source = ctx.sessions.create(SessionId('empty-source')) const controller = new SessionCommandController(ctx, controllerAgents(), '/default') - await expectFailure(controller.fork({ sessionId: source.id }), 'fork-unavailable') + await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable') await ctx.fiber.dispose() }) @@ -225,7 +230,7 @@ describe('Session fork failures', () => { origin: 'subagent', }) const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default') - await expectFailure(lineageController.fork({ sessionId: child.id }), 'internal') + await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal') await lineage.fiber.dispose() const creation = await baseContext() @@ -233,7 +238,7 @@ describe('Session fork failures', () => { const source = completedSession(creation, 'creation-source', '/workspace') vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed')) const creationController = new SessionCommandController(creation, controllerAgents(), '/default') - await expectFailure(creationController.fork({ sessionId: source.id }), 'internal') + await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal') await creation.fiber.dispose() }) @@ -251,7 +256,7 @@ describe('Session fork failures', () => { ) const controller = new SessionCommandController(ctx, controllerAgents(), '/default') - await expectFailure(controller.fork({ sessionId: source.id }), 'workspace-attach-failed') + await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed') const options = create.mock.calls[0]?.[0] if (options === undefined) throw new Error('Agent creation was not attempted') expect(options.meta).not.toHaveProperty('cwd') diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 2ea8744bd1..420a7d1bf0 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -56,7 +56,7 @@ async function commandHarness(): Promise<{ } async function expectFailure(operation: Promise, code: string): Promise { - await expect(operation).rejects.toMatchObject({ failure: { code } }) + await expect(operation).rejects.toMatchObject({ code }) } describe('Session queue commands', () => { @@ -79,21 +79,21 @@ describe('Session queue commands', () => { }, }], }, - })), 'attachment-error') + })), 'session/attachment-invalid') await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' }, - })), 'queue-item-not-found') + })), 'session/queue-item-not-found') await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' }, - })), 'queue-item-not-found') + })), 'session/queue-item-not-found') await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' }, - })), 'steer-unavailable') + })), 'session/steer-unavailable') Object.assign(agent, { status: 'idle' }) await expectFailure(Promise.resolve().then(() => controller.updateQueue({ sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' }, - })), 'steer-unavailable') + })), 'session/steer-unavailable') expect(controller.updateQueue({ sessionId: agent.id, itemId: queued.id, @@ -114,7 +114,7 @@ describe('Session queue commands', () => { await expectFailure(Promise.resolve().then(() => controller.cancel({ sessionId: SessionId('missing'), - })), 'session-not-found') + })), 'session/not-found') expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true }) expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) await ctx.fiber.dispose() @@ -209,7 +209,7 @@ describe('Session attachment authorization', () => { ) await expectFailure(noPersistenceController.attachment({ sessionId: SessionId('missing'), attachmentId: AttachmentId('att'), - }), 'session-not-found') + }), 'session/not-found') const missing = new Context() await missing.plugin(SessionStore) @@ -225,7 +225,7 @@ describe('Session attachment authorization', () => { ) await expectFailure(missingController.attachment({ sessionId: SessionId('missing'), attachmentId: 'att' as never, - }), 'session-not-found') + }), 'session/not-found') for (const thrown of [ new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'), @@ -239,7 +239,7 @@ describe('Session attachment authorization', () => { await expectFailure(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId, - }), thrown instanceof AttachmentError ? 'attachment-error' : 'internal') + }), thrown instanceof AttachmentError ? 'session/attachment-invalid' : 'gateway/internal') await fixture.ctx.fiber.dispose() } }) @@ -257,7 +257,7 @@ describe('Session attachment authorization', () => { await expectFailure(controller.attachment({ sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'), - }), 'internal') + }), 'gateway/internal') await ctx.fiber.dispose() }) }) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 39a4bf1888..f31c2cb3c7 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' import SessionController from '../src/index.ts' import type { ApiSessionAgentController } from '../src/agent.ts' @@ -124,7 +125,7 @@ describe('SessionController facade', () => { if (outcome === 'success') resolve.mockResolvedValue({ agent: live }) else if (outcome === 'domain-error') { resolve.mockResolvedValue({ - error: { code: 'internal', message: 'activation unavailable', details: {} }, + error: new RemoteError('gateway/internal', 'activation unavailable', {}), }) } else { resolve.mockRejectedValue(new Error('activation crashed')) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index cb6a635bef..e3ea48f783 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. import type { MessageId, - RpcError, RpcResponse, SessionId, SessionSearchItem, + SessionId, SessionSearchItem, SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -24,10 +24,8 @@ import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-contro import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RemoteStream, - RemoteStreamError, type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' -import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts' @@ -72,28 +70,21 @@ export function deferred(): Deferred { return { promise, resolve, reject } } -let nextRpc = 0 - -export function ok(value: T): RpcResponse { - return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } } -} - -export function err(error: RpcError): RpcResponse { - return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } } -} - -/** Successful generated Remote result for programmable domain fakes. */ -export function remoteOk(value: T): RemoteResult { +/** + * Successful generated Remote result for programmable domain fakes. + * @param value - the value the Host answers with. + * @returns the success branch of a Remote result. + */ +export function ok(value: T): RemoteResult { return { ok: true, value } } /** - * Failed generated Remote result carrying an owner's own failure vocabulary, - * which the carrier's closed RPC code set does not contain. + * Failed generated Remote result carrying the owner's declared failure. * @param error - the owner-declared failure. * @returns the failure branch of a Remote result. */ -export function remoteErr(error: RemoteFailure): RemoteResult { +export function err(error: RemoteFailure): RemoteResult { return { ok: false, error } } @@ -129,11 +120,11 @@ export class FakeApiClient { readonly followStarts: SessionId[] = [] // Programmable slots (defaults answer OK-empty); reassign per case. - onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) - onSearch: (payload: unknown) => Promise> = + onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [], hasMore: false })) - onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) - onSelectModel: (payload: SessionSelectModelRequest) => Promise> = + onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) + onSelectModel: (payload: SessionSelectModelRequest) => Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, @@ -143,19 +134,19 @@ export class FakeApiClient { : { reasoningEffort: payload.reasoningEffort }), }, })) - onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) - onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) + onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) + onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ records: [], hasMore: false })) - onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onAttachment: (payload: unknown) => Promise> = + onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onAttachment: (payload: unknown) => Promise> = () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) - onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onOpenWorkspacePath: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ opened: true as const })) + () => Promise.resolve(ok({ opened: true as const })) private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] @@ -174,30 +165,30 @@ export class FakeApiClient { lastSearchSignal: AbortSignal | undefined onSubagentList: (payload: unknown) => Promise> - = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true })) + = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) onSubagentPrompt: (payload: unknown) => Promise> - = () => Promise.resolve(remoteOk({ messageId: 'fake-message' as MessageId })) + = () => Promise.resolve(ok({ messageId: 'fake-message' as MessageId })) onSubagentInterrupt: (payload: unknown) => Promise> - = () => Promise.resolve(remoteOk({ accepted: true as const })) + = () => Promise.resolve(ok({ accepted: true as const })) onWorkspaceCreate: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true })) + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) onWorkspaceRename: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) onWorkspaceDelete: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ deleted: true })) + () => Promise.resolve(ok({ deleted: true })) onWorkspaceInsertBefore: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ workspaceIds: [] })) + () => Promise.resolve(ok({ workspaceIds: [] })) onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = - () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) onWorkspaceArchiveSession: (payload: unknown) => Promise> = - payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) + payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */ sessionRemotes(): RuntimeRemotes { @@ -209,8 +200,8 @@ export class FakeApiClient { execute: () => Promise.resolve({ ok: true, value: undefined }), }, session: { - canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)), - list: payload => this.remoteResult('session.list', payload, this.onList(payload)), + canOpenWorkspacePath: () => Promise.resolve(ok(true)), + list: payload => this.record('session.list', payload, this.onList(payload)), modelCatalog: () => Promise.resolve({ ok: true, value: { @@ -222,20 +213,20 @@ export class FakeApiClient { }), search: (payload, signal) => { this.lastSearchSignal = signal - return this.remoteResult('session.search', payload, this.onSearch(payload)) + return this.record('session.search', payload, this.onSearch(payload)) }, - create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)), - selectModel: payload => this.remoteResult( + create: payload => this.record('session.create', payload, this.onCreate(payload)), + selectModel: payload => this.record( 'session.selectModel', payload, this.onSelectModel(payload), ), - rename: payload => this.remoteResult('session.rename', payload, this.onRename(payload)), - fork: payload => this.remoteResult('session.fork', payload, this.onFork(payload)), - prompt: payload => this.remoteResult('session.prompt', payload, this.onPrompt(payload)), - attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)), - updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)), - cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)), + rename: payload => this.record('session.rename', payload, this.onRename(payload)), + fork: payload => this.record('session.fork', payload, this.onFork(payload)), + prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)), + attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)), + updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), + cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)), openWorkspacePath: payload => this.record( 'session.openWorkspacePath', payload, @@ -333,21 +324,13 @@ export class FakeApiClient { return response } - private async remoteResult( - method: string, - payload: unknown, - response: Promise>, - ): Promise> { - return (await this.record(method, payload, response)).result - } - private page(request: SessionPageRequest): Promise> { return this.fetchPage(request) } private async fetchPage( request: SessionPageRequest, - response?: Promise>, + response?: Promise>, ): Promise> { const sessionId = addressSessionId(request.address) const payload = request.address.kind === 'session' @@ -366,7 +349,7 @@ export class FakeApiClient { ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, } const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history' - const result = await this.remoteResult(method, payload, response ?? this.onHistory({ + const result = await this.record(method, payload, response ?? this.onHistory({ sessionId, throughSeq: request.throughSeq, ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, @@ -398,14 +381,8 @@ export class FakeApiClient { sessionId, maxMessages: request.maxMessages ?? 50, }) - if (!response.result.ok) { - throw new RemoteStreamError( - response.result.error.code, - response.result.error.message, - response.result.error.details, - ) - } - const page = response.result.value + if (!response.ok) throw response.error + const page = response.value const tail = page.records.at(-1) const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail)) yield { diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 8b311de746..541c104a58 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -5,10 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { entries, plainTurn } from './event-script.client.ts' const S1 = 'fk-m1' as SessionId @@ -92,10 +93,10 @@ describe('list lifecycle', () => { it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() - api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) + api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'boom', {}))) const manager = new SessionManager(fakeRemote(api)) await manager.refreshList() - expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) + expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'gateway/internal' } }) // A failed pull does not step the arrival phase: still pending. expect(manager.getListSnapshot().phase).toBe('pending') }) @@ -108,7 +109,7 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().phase).toBe('ready') // Sticky across later failures: the pull-activity axis reports the error, // the arrival phase holds. - api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) + api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'down', {}))) await manager.refreshList() expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' }) // And across an empty re-pull (empty-with-ready = truly no sessions). @@ -227,25 +228,18 @@ describe('search', () => { expect(api.lastSearchSignal).toBe(signal) }) - it('preserves business errors and folds transport failures', async () => { + it('preserves business errors and propagates a non-Remote throw', async () => { const api = new FakeApiClient() const manager = new SessionManager(fakeRemote(api)) - api.onSearch = () => Promise.resolve(err({ - code: 'internal', - message: 'index unavailable', - details: {}, - })) + api.onSearch = () => Promise.resolve(err(new RemoteError('gateway/internal', 'index unavailable', {}))) const signal = new AbortController().signal await expect(manager.search('first', signal)).resolves.toMatchObject({ ok: false, - error: { code: 'internal', message: 'index unavailable' }, + error: { code: 'gateway/internal', message: 'index unavailable' }, }) api.onSearch = () => Promise.reject(new Error('wire down')) - await expect(manager.search('second', signal)).resolves.toMatchObject({ - ok: false, - error: { code: 'internal', message: 'wire down' }, - }) + await expect(manager.search('second', signal)).rejects.toThrow('wire down') }) }) @@ -279,7 +273,7 @@ describe('subagent catalogs', () => { summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }), ] as never[] })) - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, @@ -375,7 +369,7 @@ describe('subagent catalogs', () => { it('marks a loaded parent row expandable only for a direct subagent publication', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'parent', @@ -413,7 +407,7 @@ describe('subagent catalogs', () => { manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { parentSessionId: S1, origin: 'subagent', })) - response.resolve(remoteOk({ + response.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, @@ -426,7 +420,7 @@ describe('subagent catalogs', () => { { kind: 'child', id: S1, hasChildren: true }, ]) - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'parent', activity: 'inactive', hasChildren: false, @@ -449,7 +443,7 @@ describe('subagent catalogs', () => { manager.handleSessionStatus(S1, false) manager.handleSessionStatus(S2, true) - response.resolve(remoteOk({ + response.resolve(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'stopped', @@ -472,7 +466,7 @@ describe('subagent catalogs', () => { it('marks a detached catalog child inactive without requiring a selected address', async () => { const api = new FakeApiClient() - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running', hasChildren: false, @@ -498,8 +492,8 @@ describe('subagent catalogs', () => { const refresh = manager.refreshSubagents(root) expect(manager.refreshSubagents(root)).toBe(refresh) - api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true })) - first.resolve(remoteOk({ entries: [], parentAvailable: true })) + api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + first.resolve(ok({ entries: [], parentAvailable: true })) await refresh expect(api.callsOf('subagents.list')).toHaveLength(1) @@ -524,7 +518,7 @@ describe('subagent catalogs', () => { manager.handleSessionAdded(summary(S2, { parentSessionId: root })) await vi.advanceTimersByTimeAsync(50) api.onSubagentList = () => second.promise - first.resolve(remoteOk({ + first.resolve(ok({ entries: [{ kind: 'child', id: S1, mode: 'continuable', label: 'older', activity: 'inactive', hasChildren: false, @@ -533,7 +527,7 @@ describe('subagent catalogs', () => { })) await refresh // The trailing pull is already in flight (kicked synchronously in finally). - second.resolve(remoteOk({ + second.resolve(ok({ entries: [ { kind: 'child', id: S1, mode: 'continuable', label: 'older', @@ -571,7 +565,7 @@ describe('subagent catalogs', () => { api.onSubagentList = () => first.promise const manager = new SessionManager(fakeRemote(api)) const refresh = manager.refreshSubagents(root) - first.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true })) + first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await refresh manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) @@ -583,12 +577,12 @@ describe('subagent catalogs', () => { manager.handleSessionRemoved(root) const trailing = deferred>>() api.onSubagentList = () => trailing.promise - mid.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true })) + mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await midRefresh expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) - trailing.resolve(remoteErr({ code: 'internal', message: 'trailing pull failed', details: {} })) + trailing.resolve(err(new RemoteError('gateway/internal', 'trailing pull failed', {}))) await vi.waitFor(() => { expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ state: 'error', @@ -605,7 +599,7 @@ describe('subagent catalogs', () => { it('invalidates catalog availability when the owning parent is removed', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId - api.onSubagentList = () => Promise.resolve(remoteOk({ + api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'inactive', hasChildren: false, @@ -625,12 +619,11 @@ describe('subagent catalogs', () => { }) describe('remaining branches', () => { - it('refreshList folds a transport throw into the error state', async () => { + it('refreshList propagates a non-Remote throw', async () => { const api = new FakeApiClient() api.onList = () => Promise.reject(new Error('list wire down')) const manager = new SessionManager(fakeRemote(api)) - await manager.refreshList() - expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) + await expect(manager.refreshList()).rejects.toThrow('list wire down') }) it('refreshList pushes running bits down to already-instantiated sessions', async () => { @@ -652,36 +645,32 @@ describe('remaining branches', () => { await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row expect(manager.getListSnapshot().items).toHaveLength(1) api.onCreate = () => Promise.reject(new Error('create wire down')) - expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } }) + await expect(manager.create()).rejects.toThrow('create wire down') // Business error passes through untouched. - api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} })) + api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', 'no', {}))) expect(await manager.create()).toMatchObject({ ok: false }) }) it('publishes a real Ungrouped summary from workspace-attach-failed', async () => { const api = new FakeApiClient() - api.onCreate = () => Promise.resolve(err({ - code: 'workspace-attach-failed', - message: 'published but unattached', - details: { sessionId: S1, workspaceId: 'w1' }, - } as never)) + api.onCreate = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', { + sessionId: S1, workspaceId: 'w1', + }))) const manager = new SessionManager(fakeRemote(api)) const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) - expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') }) it('reconciles a fork child published before workspace attachment fails', async () => { const api = new FakeApiClient() - api.onFork = () => Promise.resolve(err({ - code: 'workspace-attach-failed', - message: 'forked but unattached', - details: { sessionId: S2, workspaceId: 'w1' }, - } as never)) + api.onFork = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', { + sessionId: S2, workspaceId: 'w1', + }))) const manager = new SessionManager(fakeRemote(api)) const result = await manager.fork({ sessionId: S1 }) - expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S2, parentSessionId: S1, @@ -693,8 +682,8 @@ describe('remaining branches', () => { const api = new FakeApiClient() api.onCreate = () => Promise.reject(new Error('response lost')) const manager = new SessionManager(fakeRemote(api)) - const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) - expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) + await expect(manager.create({ workspaceId: 'w1' as never, sessionId: S1 })) + .rejects.toThrow('response lost') expect(manager.getListSnapshot().items).toEqual([]) manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' })) @@ -790,8 +779,8 @@ describe('connected generation', () => { manager.handleConnected() expect(manager.get(S2).getSnapshot().subagent).toEqual({ address }) - parent.resolve(remoteOk({ entries: [], parentAvailable: true })) - child.resolve(remoteOk({ entries: [], parentAvailable: true })) + parent.resolve(ok({ entries: [], parentAvailable: true })) + child.resolve(ok({ entries: [], parentAvailable: true })) await vi.waitFor(() => { expect(api.callsOf('session.list')).toHaveLength(1) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 7156ac569d..bda4b6c962 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -13,7 +13,6 @@ import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' @@ -493,17 +492,13 @@ describe('Remote Agent and Session lookup policy', () => { const sessionLookup = ctx.typert.lookups.get('session') if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') const ownershipFailure = { - failure: { - code: 'agent-busy', - details: { reason: 'use subagent delivery for this child session' }, - }, + code: 'session/agent-busy', + details: { reason: 'use subagent delivery for this child session' }, } const coldFailure = Promise.resolve(agentLookup.resolve(coldId)) const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id)) - await expect(coldFailure).rejects.toBeInstanceOf(TypertLookupFailure) await expect(coldFailure).rejects.toMatchObject(ownershipFailure) - await expect(liveFailure).rejects.toBeInstanceOf(TypertLookupFailure) await expect(liveFailure).rejects.toMatchObject(ownershipFailure) expect(resume).not.toHaveBeenCalled() expect(inspect).toHaveBeenCalledOnce() @@ -576,14 +571,14 @@ describe('subagent ownership fence', () => { expect(prompt.ok).toBe(false) if (!prompt.ok) { expect(prompt.error).toMatchObject({ - code: 'agent-busy', + code: 'session/agent-busy', details: { reason: 'use subagent delivery for this child session' }, }) } const create = await remote.create(request({ sessionId, cwd: '/proj' })) expect(create.ok).toBe(false) - if (!create.ok) expect(create.error.code).toBe('agent-busy') + if (!create.ok) expect(create.error.code).toBe('session/agent-busy') expect(resume).not.toHaveBeenCalled() expect(ctx.agents.get(sessionId)).toBeUndefined() expect(inspect).toHaveBeenCalledTimes(3) @@ -626,7 +621,7 @@ describe('subagent ownership fence', () => { })) expect(resume).toHaveBeenCalledTimes(1) expect(prompt.ok).toBe(false) - if (!prompt.ok) expect(prompt.error.code).toBe('internal') + if (!prompt.ok) expect(prompt.error.code).toBe('gateway/internal') }) it('rejects origin-marked and runtime-owned live children from generic controls', async () => { @@ -661,7 +656,7 @@ describe('subagent ownership fence', () => { const stopped = await remote.cancel(request({ sessionId: originChild.id })) expect(stopped.ok).toBe(false) - if (!stopped.ok) expect(stopped.error.code).toBe('agent-busy') + if (!stopped.ok) expect(stopped.error.code).toBe('session/agent-busy') expect(cancel).not.toHaveBeenCalled() const queued = await remote.updateQueue(request({ @@ -670,7 +665,7 @@ describe('subagent ownership fence', () => { action: { kind: 'remove' }, })) expect(queued.ok).toBe(false) - if (!queued.ok) expect(queued.error.code).toBe('agent-busy') + if (!queued.ok) expect(queued.error.code).toBe('session/agent-busy') expect(updateInbox).not.toHaveBeenCalled() const selection = await remote.selectModel(request({ @@ -679,11 +674,11 @@ describe('subagent ownership fence', () => { model: 'm', })) expect(selection.ok).toBe(false) - if (!selection.ok) expect(selection.error.code).toBe('agent-busy') + if (!selection.ok) expect(selection.error.code).toBe('session/agent-busy') const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' })) expect(create.ok).toBe(false) - if (!create.ok) expect(create.error.code).toBe('agent-busy') + if (!create.ok) expect(create.error.code).toBe('session/agent-busy') expect(ctx.agents.get(originChild.id)).toBe(originChild) }) @@ -770,10 +765,10 @@ describe('subagent ownership fence', () => { content: [{ type: 'text' as const, text: 'invalid zone' }], clientTimeZone, })) - expect(invalid).toEqual({ + expect(invalid).toMatchObject({ ok: false, error: { - code: 'invalid-time-zone', + code: 'session/invalid-time-zone', message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', details: { value: clientTimeZone }, }, @@ -801,7 +796,7 @@ describe('degenerate composition (no persistence, no factory)', () => { }) expect(response.ok).toBe(false) if (!response.ok) { - expect(response.error.code).toBe('session-not-found') + expect(response.error.code).toBe('session/not-found') } }) @@ -821,7 +816,7 @@ describe('degenerate composition (no persistence, no factory)', () => { throughSeq: -1, }) expect(response.ok).toBe(false) - if (!response.ok) expect(response.error.code).toBe('session-not-found') + if (!response.ok) expect(response.error.code).toBe('session/not-found') expect(inspect).toHaveBeenCalledOnce() }) }) @@ -850,7 +845,7 @@ describe('sessions.prompt synchronous rejection', () => { })) expect(response.ok).toBe(false) if (!response.ok) { - expect(response.error.code).toBe('agent-busy') + expect(response.error.code).toBe('session/agent-busy') expect(response.error.message).toBe('prompt rejected') expect(response.error.details).toEqual({ reason: 'Error: agent "session-throwing" lifecycle disposed', @@ -891,7 +886,7 @@ describe('sessions.prompt synchronous rejection', () => { expect(selection.ok).toBe(false) if (!selection.ok) { expect(selection.error).toMatchObject({ - code: 'agent-busy', + code: 'session/agent-busy', details: { reason: 'use subagent delivery for this child session' }, }) } diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 06e7b5a3dd..86fb18c16f 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -216,7 +216,7 @@ describe('sessions.fork', () => { for (const atSeq of [-1, 0.5]) { await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq }))) - .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } }) } expect(ctx.sessions.list()).toEqual([]) await ctx.fiber.dispose() @@ -246,7 +246,7 @@ describe('sessions.fork', () => { const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response).toMatchObject({ ok: false, - error: { code: 'fork-unavailable', details: { sessionId: source.id } }, + error: { code: 'session/fork-unavailable', details: { sessionId: source.id } }, }) if (!response.ok) expect(response.error.message).toMatch(/has not completed/) await ctx.fiber.dispose() diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index bc67a94ca6..f0d208e821 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -22,7 +22,7 @@ import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { ApiSessionAgentController } from '../src/agent.ts' import { buildModelCatalog } from '../src/catalog.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { createSessionTestRemote } from './test-remote.ts' function request

(payload: P): P { @@ -110,11 +110,7 @@ async function harness(logged?: { 'Remote Rejected', [], undefined, - new TypertRemoteFailure({ - code: 'fixture-rejected', - message: 'fixture rejected the selection', - details: { provider: 'remote-rejected' }, - }), + new RemoteError('gateway/internal', 'fixture rejected the selection', {}), )) ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', [])) ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [ @@ -225,7 +221,7 @@ describe('Web session model selection', () => { })) expect(denied).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'TOO_MANY_IMAGES' } }, }) expect(saveImage).toHaveBeenCalledTimes(2) await ctx.fiber.dispose() @@ -294,7 +290,7 @@ describe('Web session model selection', () => { })) expect(denied).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, }) expect(readImage).toHaveBeenCalledOnce() await ctx.fiber.dispose() @@ -423,7 +419,7 @@ describe('Web session model selection', () => { expect(unsupported).toMatchObject({ ok: false, error: { - code: 'model-unavailable', + code: 'session/model-unavailable', message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"', }, }) @@ -433,10 +429,10 @@ describe('Web session model selection', () => { provider: 'missing', model: 'model', })) - expect(rejected).toEqual({ + expect(rejected).toMatchObject({ ok: false, error: { - code: 'model-unavailable', + code: 'session/model-unavailable', message: 'no adapter registered for provider "missing"', details: { provider: 'missing', model: 'model' }, }, @@ -445,12 +441,12 @@ describe('Web session model selection', () => { sessionId, provider: 'remote-rejected', model: 'model', - }))).toEqual({ + }))).toMatchObject({ ok: false, error: { - code: 'fixture-rejected', + code: 'gateway/internal', message: 'fixture rejected the selection', - details: { provider: 'remote-rejected' }, + details: {}, }, }) expect(currentSelection(ctx, sessionId)) @@ -561,7 +557,7 @@ describe('Web session model selection', () => { })) expect(refused).toMatchObject({ ok: false, - error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, + error: { code: 'session/model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, }) const unavailableCatalog = await buildModelCatalog(ctx) expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false) @@ -621,9 +617,7 @@ describe('Web session model selection', () => { saveImages: () => { if (saveMode === 'error') return Promise.reject(new Error('image store offline')) if (saveMode === 'remote') { - return Promise.reject(new TypertRemoteFailure({ - code: 'fixture-rejected', message: 'fixture rejected', details: {}, - })) + return Promise.reject(new RemoteError('gateway/internal', 'fixture rejected', {})) } return Promise.resolve([savedRef]) }, @@ -643,7 +637,7 @@ describe('Web session model selection', () => { sessionId, mode: 'queue', content: [image], }))).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, }) expectValue(await remote.selectModel(request({ @@ -653,17 +647,17 @@ describe('Web session model selection', () => { sessionId, mode: 'queue', content: [{ ...image, data: '' }], }))).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'INVALID_IMAGE_BASE64' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'INVALID_IMAGE_BASE64' } }, }) saveMode = 'error' expect(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image], - }))).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + }))).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) saveMode = 'remote' expect(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image], - }))).toMatchObject({ ok: false, error: { code: 'fixture-rejected' } }) + }))).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'fixture rejected' } }) saveMode = 'success' expectValue(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image] }))) expect(followup).toHaveBeenCalledOnce() @@ -681,13 +675,13 @@ describe('Web session model selection', () => { expect(await remote.selectModel(request({ sessionId, provider: 'metadata-broken', model: 'broken', }))).toMatchObject({ - ok: false, error: { code: 'model-unavailable', message: 'reasoning metadata offline' }, + ok: false, error: { code: 'session/model-unavailable', message: 'reasoning metadata offline' }, }) expect(await remote.selectModel(request({ sessionId, provider: 'string-error', model: 'broken', }))).toMatchObject({ ok: false, - error: { code: 'model-unavailable', message: 'string selection failure' }, + error: { code: 'session/model-unavailable', message: 'string selection failure' }, }) await ctx.fiber.dispose() }) diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 2c1feb292f..ee199ddcdd 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -88,7 +88,7 @@ describe('session/openWorkspacePath', () => { }) await expect(remote.openWorkspacePath({ path: '' })) - .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } }) expect(openPath).not.toHaveBeenCalled() }) @@ -105,13 +105,13 @@ describe('session/openWorkspacePath', () => { await expect(remote.openWorkspacePath({ path: 'result.html' })) .resolves.toMatchObject({ ok: false, - error: { code: 'internal', message: 'path open failed: desktop unavailable' }, + error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' }, }) const aborted = new AbortController() - aborted.abort(new Error('cancelled')) + aborted.abort(new Error('gateway/cancelled')) await expect(remote.openWorkspacePath({ path: 'result.html' }, aborted.signal)) - .resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } }) + .resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } }) }) it('classifies opener cancellation and non-Error failures', async () => { @@ -119,7 +119,7 @@ describe('session/openWorkspacePath', () => { const aborted = new AbortController() const openPath = vi.fn() .mockImplementationOnce(async () => { - aborted.abort(new Error('cancelled')) + aborted.abort(new Error('gateway/cancelled')) throw new Error('opening stopped') }) .mockRejectedValueOnce('desktop unavailable') @@ -130,11 +130,11 @@ describe('session/openWorkspacePath', () => { }) await expect(controller.openWorkspacePath({ path: 'first.html' }, aborted.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) await expect(controller.openWorkspacePath({ path: 'second.html', }, new AbortController().signal)).rejects.toMatchObject({ - failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, + code: 'gateway/internal', message: 'path open failed: desktop unavailable', }) }) }) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 261bddbb78..6805745666 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session } from '../src/client/sessions/session.ts' import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' @@ -98,7 +99,7 @@ describe('beginSubmission', () => { describe('prompt-coupled retirement', () => { it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) + api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ text: '失败的', @@ -121,7 +122,7 @@ describe('prompt-coupled retirement', () => { it('an unidentified prompt failure leaves registered echoes alone', async () => { const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) + api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) session.beginSubmission({ text: '还在', images: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 10ce0e4269..a30c72c86b 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -6,9 +6,10 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' -import { agentPresetProjectionDefinition, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it } from 'vitest' import { createSessionTestRemote } from './test-remote.ts' @@ -26,7 +27,13 @@ function roster(ids: readonly string[]): unknown { defaultId: ids[0], resolve: (id?: string) => { const wanted = id ?? ids[0] ?? '' - if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) + if (!ids.includes(wanted)) { + return Promise.reject(new RemoteError( + 'agent-preset/not-found', + `agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`, + { agentPreset: wanted, available: ids }, + )) + } return Promise.resolve(presetOf(wanted)) }, mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), @@ -91,7 +98,7 @@ describe('session.create Agent preset identity', () => { const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' }) - expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset-not-found' } }) + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) }) it('refuses to adopt a live Session under a different preset', async () => { @@ -103,7 +110,7 @@ describe('session.create Agent preset identity', () => { expect(response).toMatchObject({ ok: false, error: { - code: 'agent-preset-conflict', + code: 'agent-preset/conflict', details: { sessionId: 's4', requestedPreset: 'standard', @@ -153,7 +160,7 @@ describe('session.create Agent preset identity', () => { expect(response).toMatchObject({ ok: false, error: { - code: 'agent-preset-conflict', + code: 'agent-preset/conflict', details: { sessionId: 's7', requestedPreset: 'standard', diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts index b1c54602e5..08d2056d54 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -90,7 +90,7 @@ describe('sessions.rename', () => { expect(response.ok).toBe(false) if (!response.ok) { expect(response.error).toMatchObject({ - code: 'title-invalid', + code: 'session/title-invalid', details: { sessionId: source.id }, }) // The message renders verbatim in the rename dialog's alert. @@ -109,7 +109,7 @@ describe('sessions.rename', () => { const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' })) expect(response.ok).toBe(false) - if (!response.ok) expect(response.error.code).toBe('internal') + if (!response.ok) expect(response.error.code).toBe('gateway/internal') }) it('answers internal when the composition mounts no session-title service', async () => { @@ -119,7 +119,7 @@ describe('sessions.rename', () => { const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' })) expect(response.ok).toBe(false) if (!response.ok) { - expect(response.error.code).toBe('internal') + expect(response.error.code).toBe('gateway/internal') expect(response.error.message).toMatch(/mounts no session-title service/) } }) diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index 02d22b4f46..0c4591556c 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -98,7 +98,7 @@ describe('session.search', () => { const list = new ApiSessionList(ctx, 0) await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({ - failure: { code: 'internal' }, + code: 'gateway/internal', }) await ctx.fiber.dispose() }) @@ -191,7 +191,7 @@ describe('session.search', () => { for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) { await expect(remote.search(request(query), new AbortController().signal)) - .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } }) } expect(searchSessions).not.toHaveBeenCalled() await ctx.fiber.dispose() @@ -353,7 +353,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error).toMatchObject({ code: 'gateway/internal' }) expect(response.error.message).toContain('100-call work budget') expect(searchSessions).toHaveBeenCalledTimes(100) }) @@ -457,7 +457,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error.code).toBe('internal') + expect(response.error.code).toBe('gateway/internal') expect(response.error.message).toContain('100-call work budget') expect(response).not.toHaveProperty('value') expect(searchSessions).toHaveBeenCalledTimes(100) @@ -486,7 +486,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) expect(searchSessions).toHaveBeenCalledTimes(2) }) @@ -507,7 +507,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) expect(response).not.toHaveProperty('value') expect(searchSessions).toHaveBeenCalledOnce() @@ -531,7 +531,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) expect(searchSessions).toHaveBeenCalledTimes(2) expect(searchSessions.mock.calls.map(([providerRequest]) => ( @@ -558,7 +558,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit)) .toEqual([20, 10, 5, 2, 1]) @@ -584,7 +584,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) expect(searchSessions).toHaveBeenCalledOnce() }) @@ -603,7 +603,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error).toMatchObject({ code: 'gateway/internal' }) expect(response.error.message).toContain('returned 21 items; maximum is 20') }) @@ -629,7 +629,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error).toMatchObject({ code: 'gateway/internal' }) expect(response.error.message).toContain('returned 11 items; maximum is 10') expect(searchSessions).toHaveBeenCalledTimes(2) }) @@ -677,7 +677,7 @@ describe('session.search', () => { expect(response.ok).toBe(false) if (response.ok) throw new Error('unreachable') - expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error).toMatchObject({ code: 'gateway/internal' }) expect(response.error.message).toContain('repeated a continuation cursor') expect(searchSessions).toHaveBeenCalledTimes(2) }) @@ -700,7 +700,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'internal' }, + error: { code: 'gateway/internal' }, }) expect(response).not.toHaveProperty('value') if (response.ok) throw new Error('unreachable') @@ -755,7 +755,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) expect(searchSessions).toHaveBeenCalledTimes(2) for (const call of searchSessions.mock.calls) { @@ -821,7 +821,7 @@ describe('session.search', () => { expect(response).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) expect(list).toHaveBeenCalledOnce() expect(locateCalls).toBe(0) @@ -863,7 +863,7 @@ describe('session.search', () => { ) expect(cancelledBeforeLookup).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) const ctx = await baseContext() @@ -881,7 +881,7 @@ describe('session.search', () => { ) expect(cancelled).toMatchObject({ ok: false, - error: { code: 'cancelled' }, + error: { code: 'gateway/cancelled' }, }) const failed = await remote.search( @@ -890,7 +890,7 @@ describe('session.search', () => { ) expect(failed.ok).toBe(false) if (failed.ok) throw new Error('unreachable') - expect(failed.error.code).toBe('internal') + expect(failed.error.code).toBe('gateway/internal') expect(failed.error.message).toContain('database unavailable') }) }) diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts index 5b5e1b2a5a..6fe4169b72 100644 --- a/packages/api/session-controller/tests/session-skills.host.spec.ts +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -161,9 +161,9 @@ describe('SessionSkillCatalog', () => { 'session "missing-skills" not found', 'SESSION_QUERY_SESSION_NOT_FOUND', ), - code: 'session-not-found', + code: 'session/not-found', }, - { error: new Error('storage offline'), code: 'internal' }, + { error: new Error('storage offline'), code: 'gateway/internal' }, ] as const)('classifies failed Session inspection as $code', async ({ error, code }) => { const ctx = await context() ctx.provide('sessionQuery', { observeSession: () => Promise.reject(error) } as never) @@ -172,7 +172,7 @@ describe('SessionSkillCatalog', () => { await expect(catalog.list( { sessionId: SessionId('missing-skills') }, new AbortController().signal, - )).rejects.toMatchObject({ failure: { code } }) + )).rejects.toMatchObject({ code }) }) it('reports an absent skill registry instead of an empty catalog', async () => { @@ -184,7 +184,7 @@ describe('SessionSkillCatalog', () => { const catalog = new SessionSkillCatalog(ctx) const failed = catalog.list({ sessionId }, new AbortController().signal) - await expect(failed).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(failed).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(failed).rejects.toThrow('skill registry is absent') }) @@ -199,10 +199,10 @@ describe('SessionSkillCatalog', () => { const catalog = new SessionSkillCatalog(ctx) const unprojected = catalog.list({ sessionId }, new AbortController().signal) - await expect(unprojected).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(unprojected).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(unprojected).rejects.toThrow('projected Session observation') const cwdless = catalog.list({ sessionId }, new AbortController().signal) - await expect(cwdless).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(cwdless).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(cwdless).rejects.toThrow('has no project cwd') }) @@ -219,7 +219,7 @@ describe('SessionSkillCatalog', () => { await expect(catalog.list({ sessionId }, new AbortController().signal)) .rejects.toMatchObject({ - failure: { code: 'internal', message: 'skill listing failed: Error: catalog offline' }, + code: 'gateway/internal', message: 'skill listing failed: Error: catalog offline', }) }) }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 150e6813d1..f59ed3c287 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -1,11 +1,11 @@ /** Session object lifecycle, event-window transport, commands, and resync behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/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, remoteErr } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' const SID = 'fk-s1' as SessionId @@ -76,21 +76,19 @@ describe('Session open', () => { expect(api.callsOf('session.history')).toEqual([]) }) - it('lands an error result in openState=error with the RpcError kept', async () => { + it('lands an error result in openState=error with the Remote failure kept', async () => { const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } })) + api.onHistory = () => Promise.resolve(err(new RemoteError('session/not-found', 'gone', { sessionId: SID }))) await session.open() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('error') - expect(snapshot.openError?.code).toBe('session-not-found') + expect(snapshot.openError?.code).toBe('session/not-found') }) - it('folds a transport throw into openState=error / internal', async () => { + it('propagates a non-Remote throw raised while opening', async () => { const { api, session } = makeSession() api.onHistory = () => Promise.reject(new Error('socket died')) - await session.open() - expect(session.getSnapshot().openState).toBe('error') - expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' }) + await expect(session.open()).rejects.toThrow('socket died') }) it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { @@ -283,23 +281,24 @@ describe('prompt and cancel errors', () => { it('lands an interrupt business failure in promptError with op=stop', async () => { const api = new FakeApiClient() - api.onSubagentInterrupt = () => Promise.resolve(remoteErr({ - code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, - })) + api.onSubagentInterrupt = () => Promise.resolve(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID }))) const session = new Session(SID, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) await session.open() const cancelled = await session.cancel() - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } }) + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent/unauthorized' } }) expect(session.getSnapshot().promptError).toMatchObject({ - op: 'stop', error: { code: 'subagent-unauthorized' }, + op: 'stop', error: { code: 'subagent/unauthorized' }, }) }) - it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { + it('sends a one-shot address to the Host under the continuable marker', async () => { const api = new FakeApiClient() + api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError( + 'subagent/not-resumable', 'subagent cannot be resumed', { childSessionId: SID }, + ))) const session = new Session(SID, fakeRemote(api), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, }) @@ -307,8 +306,15 @@ describe('prompt and cancel errors', () => { const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const cancelled = await session.cancel() - expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } }) - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) + // The Host reads the durable descriptor; the wire marker stays 'continuable'. + expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent/not-resumable' } }) + expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) + expect(api.callsOf('subagents.prompt')).toMatchObject([ + { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + ]) + expect(api.callsOf('subagents.interruptByParent')).toEqual([ + { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' }, + ]) expect(api.callsOf('session.follow')).toEqual([ { address: { @@ -318,11 +324,35 @@ describe('prompt and cancel errors', () => { }, ]) expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('subagents.prompt')).toEqual([]) - expect(api.callsOf('subagents.interruptByParent')).toEqual([]) expect(api.callsOf('session.cancel')).toEqual([]) }) + it('delivers an image continuation to the Host, which refuses it', async () => { + const api = new FakeApiClient() + api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError( + 'subagent/attachment-unsupported', + 'subagent continuation does not accept images', + { childSessionId: SID, reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, + ))) + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + }) + await session.open() + const prompted = await session.prompt( + [{ type: 'text', text: '看图' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }], + 'queue', + ) + + expect(prompted).toMatchObject({ + ok: false, + error: { code: 'subagent/attachment-unsupported', details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' } }, + }) + // The image reaches the wire unfiltered: refusing it is the Host's call. + expect(api.callsOf('subagents.prompt')).toMatchObject([ + { content: [{ type: 'text' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }] }, + ]) + }) + it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => { const { api, session } = makeSession() session.handleBlank(true) @@ -351,21 +381,20 @@ describe('prompt and cancel errors', () => { it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => { const { api, session } = makeSession() session.handleBlank(true) - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) + api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') expect(result.ok).toBe(false) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } }) expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: true, awaitingFirstTurn: true, }) }) - it('lands cancel failures in promptError with op=stop', async () => { + it('propagates a non-Remote throw raised while cancelling', async () => { const { api, session } = makeSession() api.onCancel = () => Promise.reject(new Error('cancel transport down')) - const result = await session.cancel() - expect(result.ok).toBe(false) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } }) + await expect(session.cancel()).rejects.toThrow('cancel transport down') + expect(session.getSnapshot().promptError).toBeNull() }) it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { @@ -400,32 +429,28 @@ describe('rename', () => { it('returns the business error untouched and folds a transport throw to internal', async () => { const { api, session } = makeSession() - api.onRename = () => Promise.resolve(err({ - code: 'title-invalid', message: 'empty', details: { sessionId: SID }, - } as never)) + api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'empty', { sessionId: SID }))) const rejected = await session.rename(' ') - expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } }) + expect(rejected).toMatchObject({ ok: false, error: { code: 'session/title-invalid' } }) expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() api.onRename = () => Promise.reject(new Error('rename transport down')) - const folded = await session.rename('x') - expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } }) + await expect(session.rename('x')).rejects.toThrow('rename transport down') }) }) describe('remaining branches', () => { - it('prompt transport throw folds to internal promptError', async () => { + it('propagates a non-Remote throw raised while prompting', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.reject(new Error('prompt wire down')) - const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue') - expect(result.ok).toBe(false) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } }) + await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('prompt wire down') + expect(session.getSnapshot().promptError).toBeNull() }) it('cancel business error also lands op=stop promptError', async () => { const { api, session } = makeSession() - api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } })) + api.onCancel = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'nope', { reason: 'r' }))) await session.cancel() - expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'session/agent-busy' } }) }) it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => { @@ -435,7 +460,7 @@ describe('remaining branches', () => { api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) await session.open() // err result: window unchanged - api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) + api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) await session.loadOlder() expect(eventSeqs(session)).toHaveLength(6) expect(session.getSnapshot().hasMore).toBe(true) @@ -490,7 +515,7 @@ describe('remaining branches', () => { const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('error') expect(snapshot.openError).toMatchObject({ - code: 'internal', message: 'session event stream page did not end at its requested cursor', + code: 'gateway/internal', message: 'session event stream page did not end at its requested cursor', }) expect(eventSeqs(session)).toEqual([]) }) @@ -508,7 +533,7 @@ describe('remaining branches', () => { const { api, session } = makeSession() await follow(api, ev.user(0, '冷态帧')) expect(eventSeqs(session)).toEqual([]) - api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) + api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) await session.open() await follow(api, ev.user(0, '错态帧')) expect(eventSeqs(session)).toEqual([]) @@ -518,16 +543,14 @@ describe('remaining branches', () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() - const failure = { - code: 'session-not-found', - message: 'session disappeared', - details: { sessionId: SID }, - } + const failure = new RemoteError('session/not-found', 'session disappeared', { sessionId: SID }) - api.failStreams(new RemoteStreamError(failure.code, failure.message, failure.details)) + api.failStreams(failure) await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) - expect(session.getSnapshot().openError).toEqual(failure) + expect(session.getSnapshot().openError).toMatchObject({ + code: failure.code, message: failure.message, details: failure.details, + }) }) it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => { @@ -545,10 +568,10 @@ describe('remaining branches', () => { follow(api, ev.user(10, '洞二')), ]) await vi.waitFor(() => { expect(repairs).toBe(1) }) - gate.reject(new Error('repair wire down')) + gate.reject(new RemoteError('gateway/internal', 'repair wire down', {})) await deliveries await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) - expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'repair wire down' }) + expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'repair wire down' }) expect(eventSeqs(session)).toHaveLength(6) }) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 4e4bade67c..c98ff399fb 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts' import { scopeOf } from '../src/client/scope.ts' import type { SessionFollowFrame } from '../src/types.ts' @@ -18,7 +19,6 @@ import { err, fakeRemote, ok, - remoteOk, type RuntimeRemotes, } from './fake-api.client.ts' @@ -525,7 +525,7 @@ describe('catalog-addressed navigation', () => { b.api.onSubagentList = (payload) => { const parentSessionId = payload as SessionId if (parentSessionId === sid('root')) { - return Promise.resolve(remoteOk({ + return Promise.resolve(ok({ entries: [{ kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child', activity: 'inactive', hasChildren: true, @@ -534,7 +534,7 @@ describe('catalog-addressed navigation', () => { })) } if (parentSessionId === sid('child')) { - return Promise.resolve(remoteOk({ + return Promise.resolve(ok({ entries: [{ kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', activity: 'inactive', hasChildren: false, @@ -542,7 +542,7 @@ describe('catalog-addressed navigation', () => { parentAvailable: false, })) } - return Promise.resolve(remoteOk({ entries: [], parentAvailable: false })) + return Promise.resolve(ok({ entries: [], parentAvailable: false })) } await feedList(b, [ { id: 'root' }, @@ -564,7 +564,7 @@ describe('catalog-addressed navigation', () => { b.api.onSubagentList = (payload) => { const parentSessionId = payload as SessionId if (parentSessionId === sid('root')) { - return Promise.resolve(remoteOk({ + return Promise.resolve(ok({ entries: [{ kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child', activity: 'inactive', hasChildren: true, @@ -573,7 +573,7 @@ describe('catalog-addressed navigation', () => { })) } if (parentSessionId === sid('child')) { - return Promise.resolve(remoteOk({ + return Promise.resolve(ok({ entries: [{ kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', activity: 'inactive', hasChildren: false, @@ -581,7 +581,7 @@ describe('catalog-addressed navigation', () => { parentAvailable: false, })) } - return Promise.resolve(remoteOk({ entries: [], parentAvailable: false })) + return Promise.resolve(ok({ entries: [], parentAvailable: false })) } await feedList(b, [{ id: 'root' }]) await b.svc.refreshSubagents(sid('root')) @@ -611,15 +611,12 @@ describe('create', () => { b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') })) await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh') expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }]) - b.api.onCreate = () => Promise.resolve({ - rpcId: 'e' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } }, - } as never) + b.api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', '爆了', {}))) const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error) expect(failure).toBeInstanceOf(SessionCreateError) expect(failure).toMatchObject({ requestedSessionId: 'candidate', - rpcError: { code: 'internal', message: '爆了' }, + rpcError: { code: 'gateway/internal', message: '爆了' }, }) }) @@ -637,16 +634,11 @@ describe('create', () => { it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => { const b = bench() - b.api.onCreate = () => Promise.resolve({ - rpcId: 'attach' as never, - result: { - ok: false, - error: { - code: 'workspace-attach-failed', message: 'ledger unavailable', - details: { sessionId: sid('published'), workspaceId: 'ws' }, - }, - }, - } as never) + b.api.onCreate = () => Promise.resolve(err(new RemoteError( + 'session/workspace-attach-failed', + 'ledger unavailable', + { sessionId: sid('published'), workspaceId: 'ws' }, + ))) const failure = await b.svc.create({ workspaceId: 'ws' as never, sessionId: sid('published'), @@ -655,7 +647,7 @@ describe('create', () => { expect(failure).toBeInstanceOf(SessionCreateError) expect(failure).toMatchObject({ requestedSessionId: 'published', - rpcError: { code: 'workspace-attach-failed' }, + rpcError: { code: 'session/workspace-attach-failed' }, }) expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true }) }) @@ -723,12 +715,10 @@ describe('fork', () => { }) await feedList(b, [{ id: 'source' }]) b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) - b.api.onRename = () => Promise.resolve(err({ - code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') }, - } as never)) + b.api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') }))) await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })) - .rejects.toThrow('fork child rename failed: title-invalid: rejected') + .rejects.toThrow('fork child rename failed: session/title-invalid: rejected') expect(b.svc.binding(sid('child'))).toBeDefined() }) }) @@ -785,10 +775,7 @@ describe('blank mirror', () => { const b = bench() await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }]) const session = b.svc.binding(sid('s1'))!.session - b.api.onPrompt = () => Promise.resolve({ - rpcId: 'busy' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } }, - } as never) + b.api.onPrompt = () => Promise.resolve(err(new RemoteError('gateway/internal', 'agent busy', {}))) const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue') expect(result.ok).toBe(false) // No flip on failure: local stays aligned with the host authority diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index e2e6292bce..3d7d549a26 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -14,7 +14,8 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import { vi } from 'vitest' import { - TypertRemoteFailure, + RemoteError, + remoteErrorOf, type RemoteResult, } from '@deepseek-ai/dsh-typert-protocol' import SessionController from '../src/index.ts' @@ -224,14 +225,13 @@ function remoteResult( .catch((error: unknown) => ({ ok: false as const, error: signal?.aborted === true - ? { code: 'cancelled', message: 'request was aborted', details: {} } - : error instanceof TypertRemoteFailure - ? error.failure - : { - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - }, + ? new RemoteError('gateway/cancelled', 'request was aborted', {}) + : remoteErrorOf(error) + ?? new RemoteError( + 'gateway/internal', + error instanceof Error ? error.message : String(error), + {}, + ), })) } diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 36afa47632..6e00845e35 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -2,17 +2,17 @@ import { describe, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, - RemoteStreamError, type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { createSessionControlStream, SessionEventStream, - sessionStreamFailure, type SessionJournalChange, type SessionRemote, } from '../src/client/index.ts' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import type { SessionAddress, SessionControlFrame, @@ -73,12 +73,18 @@ function snapshot( } } -function sessionClient(remote: SessionTransportRemote) { +function sessionClient(remote: SessionTransportRemote): SessionRemotes { return { session: remote as SessionRemote, $stream: (options: RemoteStreamOptions) => ( new RemoteStream(AVAILABLE_CONNECTION, options) ), + commands: { execute: () => Promise.reject(new Error('stream tests never run commands')) }, + subagents: { + list: () => Promise.reject(new Error('stream tests never read the subagent catalog')), + prompt: () => Promise.reject(new Error('stream tests never prompt a subagent')), + interruptByParent: () => Promise.reject(new Error('stream tests never interrupt a subagent')), + }, } } @@ -295,7 +301,7 @@ describe('Session Client stream adapters', () => { }) it('turns a pagination failure into a typed stream failure', async () => { - const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const + const failure = new RemoteError('session/not-found', 'missing', { sessionId: 'session-1' as never }) const remote = new ScriptedSessionRemote( [{ frames: [snapshot(-1, [])], hold: true }], [{ ok: false, error: failure }], @@ -306,11 +312,8 @@ describe('Session Client stream adapters', () => { }) await stream.open({}) - await expect(stream.prepend({})).rejects.toBeInstanceOf(RemoteStreamError) + await expect(stream.prepend({})).rejects.toMatchObject({ code: 'session/not-found' }) await expect(stream.open({})).rejects.toThrow('already opened') - expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details))) - .toEqual(failure) - expect(sessionStreamFailure(new Error('local'))).toBeUndefined() expect(remote.signals[0]?.aborted).toBe(false) expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }]) await stream.dispose() diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 8461d99a24..e04b430c71 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -296,7 +296,7 @@ describe('SessionHistoryController', () => { id: session.id, events: [event('fixture/start', 0), skipped, gap], } as unknown as Session, gap) - await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(followed.next()).rejects.toMatchObject({ code: 'gateway/internal' }) }) it('opens an empty source at cursor -1', async () => { @@ -396,15 +396,15 @@ describe('SessionHistoryController', () => { mode: 'continuable', }, throughSeq: 0, - }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) await expect(transport.page({ address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' }, throughSeq: 0, - }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) await expect(transport.page({ address: { kind: 'session', sessionId: childSessionId }, throughSeq: 0, - }, signal)).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + }, signal)).rejects.toMatchObject({ code: 'session/agent-busy' }) }) it('preserves a cold inspection failure for the Gateway error branch', async () => { @@ -438,10 +438,10 @@ describe('SessionHistoryController', () => { { address, throughSeq: -1, maxMessages: 0 }, { address, throughSeq: -1, maxMessages: 1.5 }, ]) { - await expect(transport.page(request, signal())).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + await expect(transport.page(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' }) } await expect(transport.page({ address, throughSeq: 0 }, signal())) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) const corrupt = await setup() const corruptId = SessionId('missing-through-seq') @@ -455,7 +455,7 @@ describe('SessionHistoryController', () => { }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) for (const maxMessages of [0, 0.5]) { const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() - await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } }) @@ -463,7 +463,7 @@ describe('SessionHistoryController', () => { const { ctx, transport } = await setup() const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') } await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) const inspect = vi.fn(() => Promise.resolve(undefined)) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { @@ -471,7 +471,7 @@ describe('SessionHistoryController', () => { inspect, }) as never) await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) await expect(transport.page({ address: { kind: 'subagent', @@ -480,7 +480,7 @@ describe('SessionHistoryController', () => { mode: 'continuable', }, throughSeq: -1, - }, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } }) + }, signal())).rejects.toMatchObject({ code: 'subagent/not-found' }) expect(inspect).toHaveBeenCalledTimes(2) }) @@ -494,7 +494,7 @@ describe('SessionHistoryController', () => { inspect: () => Promise.resolve({ meta: firstHeader, events: [] }), }) as never) await expect(first.transport.page({ address, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) const second = await setup() const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } @@ -504,7 +504,7 @@ describe('SessionHistoryController', () => { inspect: () => Promise.resolve({ meta: inspected, events: [] }), }) as never) await expect(second.transport.page({ address, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) }) it('serves cold ordinary history and validates every durable subagent descriptor state', async () => { @@ -538,18 +538,18 @@ describe('SessionHistoryController', () => { const missing = await setup() cold(missing.ctx, childHeader, []) await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } }) + .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const corrupt = await setup() cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })]) await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal())) - .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } }) + .rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } }) const ordinaryChild = await setup() const { origin: _origin, ...ordinaryChildHeader } = childHeader cold(ordinaryChild.ctx, ordinaryChildHeader, []) await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal())) - .rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + .rejects.toMatchObject({ code: 'subagent/unauthorized' }) }) it('reports an unavailable descriptor when an observed child has no projection value', async () => { @@ -578,7 +578,7 @@ describe('SessionHistoryController', () => { address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' }, throughSeq: -1, }, signal())).rejects.toMatchObject({ - failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } }, + code: 'subagent/catalog-diagnostic', details: { reason: 'unsupported' }, }) await ctx.fiber.dispose() }) diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index bea21672b7..aaa1503581 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -42,6 +42,7 @@ { "path": "../../session-query/session-query" }, { "path": "../../skill/skill" }, { "path": "../../subagent/subagent" }, + { "path": "../../util/time" }, { "path": "../../typert/protocol" }, { "path": "../../typert/registry" }, { "path": "../../workspace/workspace" } diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts index a9db113e35..99c42a4cf0 100644 --- a/packages/api/settings-controller/src/credentials.ts +++ b/packages/api/settings-controller/src/credentials.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' /** @@ -30,11 +30,7 @@ const unsetRequestSchema = z.object({ ref: credentialRefSchema }) function parseRequest(method: string, schema: z.ZodType, value: unknown): T { const parsed = schema.safeParse(value) if (!parsed.success) { - throw new TypertRemoteFailure({ - code: 'bad-request', - message: `invalid payload for ${method}`, - details: { issues: parsed.error.issues }, - }) + throw new RemoteError('gateway/bad-request', `invalid payload for ${method}`, { issues: parsed.error.issues }) } return parsed.data } @@ -78,9 +74,10 @@ export class CredentialsController extends TypertRemoteService { * Describe several references for one configuration surface. Batched because * a settings page describes every reference its rows name at once, and one * round trip keeps those rows from settling separately. - * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar + * rejects the whole call as `gateway/bad-request`. * @returns one view per requested name, keyed by that name. - * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted. + * @throws RemoteError when the request is invalid or no credential provider is mounted. */ @Remote async describe(refs: string[]): Promise> { @@ -97,7 +94,7 @@ export class CredentialsController extends TypertRemoteService { * this direction only: no read path returns it. * @param ref - reference name to store under. * @param value - the non-empty secret value. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async set(ref: string, value: string): Promise { @@ -110,7 +107,7 @@ export class CredentialsController extends TypertRemoteService { /** * Remove one reference from a configuration surface. * @param ref - reference name to remove. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async unset(ref: string): Promise { @@ -124,17 +121,17 @@ export class CredentialsController extends TypertRemoteService { private provider(): CredentialProvider { const credentials = this.ctx.get('credentials') if (credentials === undefined) { - throw new TypertRemoteFailure({ - code: 'internal', - message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', - details: {}, - }) + throw new RemoteError( + 'gateway/internal', + 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', + {}, + ) } return credentials } /** - * Run one remote write and report every refusal as `credential-rejected` + * Run one remote write and report every refusal as `credential/rejected` * carrying the seam's own message: a read-only source shadowing the reference * is what a configuration surface must show verbatim. Callers brand the * reference before entering, so a name outside the grammar never reaches this @@ -145,11 +142,12 @@ export class CredentialsController extends TypertRemoteService { try { await write() } catch (error: unknown) { - throw new TypertRemoteFailure({ - code: 'credential-rejected', - message: error instanceof Error ? error.message : String(error), - details: { ref }, - }) + throw new RemoteError( + 'credential/rejected', + error instanceof Error ? error.message : String(error), + { ref }, + { cause: error }, + ) } } } diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index 5fa81d1518..19056ef1dd 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -10,12 +10,8 @@ import { dirname } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' -import { - InvalidPresetIdError, - PresetExistsError, - PresetNotWritableError, - UnknownPresetError, -} from '@deepseek-ai/dsh-agent-presets' +// Type-only: resolves the `agentPresets` Context augmentation this controller reads. +import type {} from '@deepseek-ai/dsh-agent-presets' import { canOpenNativePath, openNativePath, @@ -27,7 +23,7 @@ import type { SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-settings/types' import type { JsonValue } from '@deepseek-ai/dsh-session/types' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import { CredentialsController } from './credentials.ts' import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts' @@ -88,7 +84,7 @@ declare module '@deepseek-ai/cordis' { * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot * ride a response. Writes expose the settings service's merge, replacement, * and path-addressed operations, and classify every provider refusal as - * `settings-conflict` or `settings-rejected` with the service's message. + * `settings/conflict` or `settings/rejected` with the service's message. */ export class SettingsController extends TypertRemoteService { static Config: Schema = Schema.object({ nativeOpen: Schema.boolean() }) @@ -116,7 +112,7 @@ export class SettingsController extends TypertRemoteService { * Describe every registered namespace for a configuration page: redacted * layered values plus the serialized schema the page renders its form from. * @returns provider writability, local-document presence, and one view per namespace. - * @throws TypertRemoteFailure when no settings provider is mounted. + * @throws RemoteError when no settings provider is mounted. */ @Remote describe(): SettingsDescribeValue { @@ -143,7 +139,7 @@ export class SettingsController extends TypertRemoteService { * @param patch - fields to merge into the user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote update( @@ -160,7 +156,7 @@ export class SettingsController extends TypertRemoteService { * @param section - complete replacement user section. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote replace( @@ -179,7 +175,7 @@ export class SettingsController extends TypertRemoteService { * @param ops - the edits to apply, in order. * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. * @returns the namespace's redacted view after the write. - * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async mutate( @@ -194,29 +190,29 @@ export class SettingsController extends TypertRemoteService { * Materialize the provider-owned settings document and open it in a native text editor. * @param signal - caller lifetime; abort terminates preparation or the native command. * @returns confirmation after the native opener accepts the document. - * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + * @throws RemoteError when no document exists, preparation fails, or opening fails. */ @Remote async openSettingsDocument(signal: AbortSignal): Promise { const settings = this.provider() - if (isAborted(signal)) throw cancelled('settings document open was aborted') + if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {}) let path: string | undefined try { path = await settings.prepareDocument() } catch (error: unknown) { - if (isAborted(signal)) throw cancelled('settings document preparation was aborted') - throw internal(`settings document preparation failed: ${messageOf(error)}`) + if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document preparation was aborted', {}) + throw new RemoteError('gateway/internal', `settings document preparation failed: ${messageOf(error)}`, {}, { cause: error }) } if (path === undefined) { - throw internal('settings provider has no local document to open') + throw new RemoteError('gateway/internal', 'settings provider has no local document to open', {}) } - if (isAborted(signal)) throw cancelled('settings document open was aborted') + if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {}) try { await this.openTextFile(path, signal) return { opened: true } } catch (error: unknown) { - if (isAborted(signal)) throw cancelled('settings document open was aborted') - throw internal(`path open failed: ${messageOf(error)}`) + if (isAborted(signal)) throw new RemoteError('gateway/cancelled', 'settings document open was aborted', {}) + throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error }) } } @@ -225,7 +221,7 @@ export class SettingsController extends TypertRemoteService { * @param agentPreset - preset id resolved against Host-owned roots. * @param signal - caller lifetime; abort terminates the native command. * @returns an opened confirmation or the resolved directory for text display. - * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened. */ @Remote async openAgentPresetDirectory( @@ -233,35 +229,32 @@ export class SettingsController extends TypertRemoteService { signal: AbortSignal, ): Promise { if (agentPreset.length === 0) { - throw new TypertRemoteFailure({ - code: 'bad-request', message: 'agent preset id must not be empty', details: {}, - }) + throw new RemoteError('gateway/bad-request', 'agent preset id must not be empty', {}) } const presets = this.ctx.get('agentPresets') if (presets === undefined) { - throw new TypertRemoteFailure({ - code: 'agent-preset-not-found', - message: 'this deployment composes no agent presets', - details: { agentPreset, available: [] }, - }) + throw new RemoteError( + 'agent-preset/not-found', + 'this deployment composes no agent presets', + { agentPreset, available: [] }, + ) } - let directory: string - try { - const preset = await presets.resolve(agentPreset) - if (preset.trust !== 'user') { - throw new PresetNotWritableError(preset.id, 'it ships with the deployment') - } - directory = dirname(preset.path) - } catch (error: unknown) { - throw presetFailure(agentPreset, error) + const preset = await presets.resolve(agentPreset) + if (preset.trust !== 'user') { + throw new RemoteError( + 'agent-preset/read-only', + `agent-presets: preset "${preset.id}" cannot be written: it ships with the deployment`, + { agentPreset: preset.id, reason: 'it ships with the deployment' }, + ) } + const directory = dirname(preset.path) if (!this.canOpenPath()) return { opened: false, path: directory } try { await this.openPath(directory, signal) return { opened: true } } catch (error: unknown) { - if (signal.aborted) throw cancelled('path open was aborted') - throw internal(`path open failed: ${messageOf(error)}`) + if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) + throw new RemoteError('gateway/internal', `path open failed: ${messageOf(error)}`, {}, { cause: error }) } } @@ -273,11 +266,7 @@ export class SettingsController extends TypertRemoteService { ): Promise { const parsed = settingsNamespaceRequestSchema.safeParse({ ns }) if (!parsed.success) { - throw new TypertRemoteFailure({ - code: 'bad-request', - message: `invalid payload for settings.${mode}`, - details: { issues: parsed.error.issues }, - }) + throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues }) } const settings = this.provider() let branded @@ -286,7 +275,7 @@ export class SettingsController extends TypertRemoteService { // unregistered one does. branded = settingsNamespace(parsed.data.ns) } catch (error: unknown) { - throw rejected(ns, error) + throw new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error }) } try { if (mode === 'update') await settings.update(branded, input, expectedRevision) @@ -299,11 +288,7 @@ export class SettingsController extends TypertRemoteService { if (descriptor === undefined) { // The write committed but the namespace vanished before this read: only a // concurrent registrant disposal can produce it. - throw new TypertRemoteFailure({ - code: 'internal', - message: `settings namespace "${ns}" was disposed after the ${mode}`, - details: {}, - }) + throw new RemoteError('gateway/internal', `settings namespace "${ns}" was disposed after the ${mode}`, {}) } return namespaceView(descriptor) } @@ -312,11 +297,11 @@ export class SettingsController extends TypertRemoteService { private provider(): SettingsProvider { const settings = this.ctx.get('settings') if (settings === undefined) { - throw new TypertRemoteFailure({ - code: 'internal', - message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', - details: {}, - }) + throw new RemoteError( + 'gateway/internal', + 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', + {}, + ) } return settings } @@ -326,40 +311,6 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } -function internal(message: string): TypertRemoteFailure { - return new TypertRemoteFailure({ code: 'internal', message, details: {} }) -} - -function cancelled(message: string): TypertRemoteFailure { - return new TypertRemoteFailure({ code: 'cancelled', message, details: {} }) -} - -function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure { - if (error instanceof UnknownPresetError) { - return new TypertRemoteFailure({ - code: 'agent-preset-not-found', - message: error.message, - details: { agentPreset: error.presetId, available: [...error.available] }, - }) - } - if (error instanceof PresetNotWritableError) { - return new TypertRemoteFailure({ - code: 'agent-preset-read-only', - message: error.message, - details: { agentPreset, reason: error.message }, - }) - } - if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { - return new TypertRemoteFailure({ - code: 'agent-preset-invalid', - message: error.message, - details: { agentPreset, reason: error.message }, - }) - } - if (error instanceof TypertRemoteFailure) return error - return internal(`agent preset "${agentPreset}": ${String(error)}`) -} - /** * Classify one seam refusal. A stale writer is its own outcome, not a malformed * request: the client must re-read and re-apply rather than treat the write as @@ -368,19 +319,16 @@ function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure * @param error - whatever the seam threw. * @returns the failure to raise for that refusal. */ -function rejected(ns: string, error: unknown): TypertRemoteFailure { +function rejected(ns: string, error: unknown): RemoteError { if (error instanceof SettingsConflictError) { - return new TypertRemoteFailure({ - code: 'settings-conflict', - message: error.message, - details: { ns, expected: error.expected, actual: error.actual }, - }) + return new RemoteError( + 'settings/conflict', + error.message, + { ns, expected: error.expected, actual: error.actual }, + { cause: error }, + ) } - return new TypertRemoteFailure({ - code: 'settings-rejected', - message: error instanceof Error ? error.message : String(error), - details: { ns }, - }) + return new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: error }) } export default SettingsController diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index 5fde28ae89..cc9806aa84 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -7,28 +7,26 @@ * @module @deepseek-ai/dsh-api-settings-controller/types */ -/** Stable settings failure details returned by the `settings` namespace. */ -export interface SettingsErrorDetailsMap { - /** - * Every seam refusal that is not a stale write: an unregistered or malformed - * namespace, a read-only provider, schema validation, storage. - */ - 'settings-rejected': { readonly ns: string } - /** - * The stored revision moved after the caller read it. Its own outcome rather - * than an invalid request: the caller must re-read and re-apply. - */ - 'settings-conflict': { readonly ns: string; readonly expected: number; readonly actual: number } -} - -/** Settings business failure carried by a rejected Remote call. */ -export type SettingsError = { - [Code in keyof SettingsErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: SettingsErrorDetailsMap[Code] +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** + * Every seam refusal that is not a stale write: an unregistered or malformed + * namespace, a read-only provider, schema validation, storage. + */ + 'settings/rejected': { readonly ns: string } + /** + * The stored revision moved after the caller read it. Its own outcome rather + * than an invalid request: the caller must re-read and re-apply. + */ + 'settings/conflict': { readonly ns: string; readonly expected: number; readonly actual: number } + /** + * The provider refused a valid credential write, for example because a + * read-only source shadows the reference. The details name only the + * reference, never the value. + */ + 'credential/rejected': { readonly ref: string } } -}[keyof SettingsErrorDetailsMap] +} /** Confirmation that the settings document was handed to the native editor. */ export interface SettingsDocumentOpenValue { @@ -39,21 +37,3 @@ export interface SettingsDocumentOpenValue { export type AgentPresetDirectoryOpenValue = | { readonly opened: true } | { readonly opened: false; readonly path: string } - -/** Stable credential failure details returned by the `credentials` namespace. */ -export interface CredentialErrorDetailsMap { - /** - * The provider refused a valid write, for example because a read-only source - * shadows the reference. The details name only the reference, never the value. - */ - 'credential-rejected': { readonly ref: string } -} - -/** Credential business failure carried by a rejected Remote call. */ -export type CredentialError = { - [Code in keyof CredentialErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: CredentialErrorDetailsMap[Code] - } -}[keyof CredentialErrorDetailsMap] diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts index a27af7687c..9ac14d9eb8 100644 --- a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' -import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import { remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' import CredentialsController from '../src/credentials.ts' import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' @@ -60,9 +60,8 @@ describe('the credentials Remote namespace a configuration surface calls', () => () => ctx.credentialsController.unset('DEEPSEEK_API_KEY'), ]) { const failure = await call().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - expect((failure as TypertRemoteFailure).failure).toEqual({ - code: 'internal', + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'gateway/internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {}, }) @@ -87,8 +86,7 @@ describe('the credentials Remote namespace a configuration surface calls', () => () => controller.unset('not a var'), ]) { const failure = await call().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) } }) @@ -97,7 +95,7 @@ describe('the credentials Remote namespace a configuration surface calls', () => const accepted = Array.from({ length: 64 }, (_unused, index) => `REF_${String(index)}`) expect(Object.keys(await controller.describe(accepted))).toHaveLength(64) const failure = await controller.describe([...accepted, 'REF_64']).catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) }) it('answers only the fields the view declares, whatever a provider returns', async () => { @@ -117,12 +115,11 @@ describe('the credentials Remote namespace a configuration surface calls', () => .toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } }) }) - it('reports a refused write as credential-rejected naming only the reference', async () => { + it('reports a refused write as credential/rejected naming only the reference', async () => { const controller = await boot({}, RejectingCredentials) const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - const { code, message, details } = (failure as TypertRemoteFailure).failure - expect(code).toBe('credential-rejected') + const { code, message, details } = remoteErrorOf(failure) ?? {} + expect(code).toBe('credential/rejected') expect(message).toContain('read-only source') expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' }) }) @@ -130,12 +127,12 @@ describe('the credentials Remote namespace a configuration surface calls', () => it('reports an empty value as bad-request', async () => { const controller = await boot() const failure = await controller.set('DEEPSEEK_API_KEY', '').catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) }) it('stringifies a refusal that is not an Error', async () => { const controller = await boot({}, LiteralRejectingCredentials) const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure.message).toBe('the store refused') + expect(remoteErrorOf(failure)?.message).toBe('the store refused') }) }) diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index 7195e6650f..d9b0481cdd 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -1,14 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { - InvalidPresetIdError, - PresetExistsError, - UnknownPresetError, -} from '@deepseek-ai/dsh-agent-presets' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' import SettingsController from '../src/index.ts' import { MemorySettings } from '../../../settings/settings/tests/memory.ts' @@ -103,9 +98,8 @@ describe('the settings Remote namespace a configuration page calls', () => { ] for (const call of calls) { const failure = await Promise.resolve().then(call).catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - expect((failure as TypertRemoteFailure).failure).toEqual({ - code: 'internal', + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'gateway/internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', details: {}, }) @@ -186,16 +180,15 @@ describe('the settings Remote namespace a configuration page calls', () => { expect(replaced.secrets).toEqual([{ path: ['apiKey'], set: false }]) }) - it('refuses a stale write as settings-conflict carrying both revisions', async () => { + it('refuses a stale write as settings/conflict carrying both revisions', async () => { const { controller } = await boot() const held = controller.describe().namespaces[0]!.revision await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], held) const failure = await controller .mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'light' }], held) .catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - const { code, details } = (failure as TypertRemoteFailure).failure - expect(code).toBe('settings-conflict') + const { code, details } = remoteErrorOf(failure) ?? {} + expect(code).toBe('settings/conflict') expect(details).toMatchObject({ ns: 'ui-test', expected: held }) }) @@ -204,8 +197,8 @@ describe('the settings Remote namespace a configuration page calls', () => { for (const ns of ['Not A Namespace', 'unregistered']) { const failure = await controller.mutate(ns, [{ op: 'unset', path: ['preference'] }], undefined) .catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ - code: 'settings-rejected', + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'settings/rejected', details: { ns }, }) } @@ -219,17 +212,16 @@ describe('the settings Remote namespace a configuration page calls', () => { () => controller.mutate('', [], undefined), ]) { const failure = await call().catch((error: unknown) => error) - expect(failure).toBeInstanceOf(TypertRemoteFailure) - expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' }) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'gateway/bad-request' }) } }) - it('reports a refused write as settings-rejected carrying the seam message', async () => { + it('reports a refused write as settings/rejected carrying the seam message', async () => { const { controller } = await boot(RefusingSettings) const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined) .catch((error: unknown) => error) - const { code, message } = (failure as TypertRemoteFailure).failure - expect(code).toBe('settings-rejected') + const { code, message } = remoteErrorOf(failure) ?? {} + expect(code).toBe('settings/rejected') expect(message).toContain('read-only in this deployment') }) @@ -237,15 +229,15 @@ describe('the settings Remote namespace a configuration page calls', () => { const { controller } = await boot(LiteralRefusingSettings) const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined) .catch((error: unknown) => error) - expect((failure as TypertRemoteFailure).failure.message).toBe('the document is locked') + expect(remoteErrorOf(failure)?.message).toBe('the document is locked') }) it('reports a namespace disposed between the write and its read-back', async () => { const { controller } = await boot(VanishingSettings) const failure = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined) .catch((error: unknown) => error) - const { code, message } = (failure as TypertRemoteFailure).failure - expect(code).toBe('internal') + const { code, message } = remoteErrorOf(failure) ?? {} + expect(code).toBe('gateway/internal') expect(message).toContain('was disposed after the mutate') }) @@ -265,13 +257,13 @@ describe('the settings Remote namespace a configuration page calls', () => { it('preserves settings-document absence, failure, and cancellation', async () => { const absent = await boot() const missingDocument = absent.controller.openSettingsDocument(new AbortController().signal) - await expect(missingDocument).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(missingDocument).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(missingDocument).rejects.toThrow('no local document') const failed = await boot(DocumentSettings) vi.spyOn(failed.ctx.settings, 'prepareDocument').mockRejectedValue(new Error('read failed')) const failedRead = failed.controller.openSettingsDocument(new AbortController().signal) - await expect(failedRead).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(failedRead).rejects.toMatchObject({ code: 'gateway/internal' }) await expect(failedRead).rejects.toThrow('read failed') const cancelled = new AbortController() @@ -279,7 +271,7 @@ describe('the settings Remote namespace a configuration page calls', () => { const prepare = vi.spyOn(failed.ctx.settings, 'prepareDocument') prepare.mockClear() await expect(failed.controller.openSettingsDocument(cancelled.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) expect(prepare).not.toHaveBeenCalled() }) @@ -296,7 +288,7 @@ describe('the settings Remote namespace a configuration page calls', () => { abort.abort(new Error('cancelled')) prepared.resolve('/tmp/settings.yaml') - await expect(opening).rejects.toMatchObject({ failure: { code: 'cancelled' } }) + await expect(opening).rejects.toMatchObject({ code: 'gateway/cancelled' }) expect(openTextFile).not.toHaveBeenCalled() }) @@ -309,9 +301,7 @@ describe('the settings Remote namespace a configuration page calls', () => { }) await expect(controller.openSettingsDocument(new AbortController().signal)) - .rejects.toMatchObject({ - failure: { code: 'internal', message: 'path open failed: no default editor' }, - }) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: no default editor' }) }) it('classifies cancellation while preparing or opening the settings document', async () => { @@ -324,7 +314,7 @@ describe('the settings Remote namespace a configuration page calls', () => { }) const preparingController = new SettingsController(preparing) await expect(preparingController.openSettingsDocument(prepareAbort.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) const opening = new Context() await opening.plugin(DocumentSettings) @@ -337,7 +327,7 @@ describe('the settings Remote namespace a configuration page calls', () => { }, }) await expect(openingController.openSettingsDocument(openAbort.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) }) it('opens a user Agent preset directory or returns its path without a native opener', async () => { @@ -391,11 +381,11 @@ describe('the settings Remote namespace a configuration page calls', () => { } as never) const controller = new SettingsController(ctx) await expect(controller.openAgentPresetDirectory('standard', new AbortController().signal)) - .rejects.toMatchObject({ failure: { code: 'agent-preset-read-only' } }) + .rejects.toMatchObject({ code: 'agent-preset/read-only' }) const missing = new SettingsController(new Context()) await expect(missing.openAgentPresetDirectory('mine', new AbortController().signal)) - .rejects.toMatchObject({ failure: { code: 'agent-preset-not-found' } }) + .rejects.toMatchObject({ code: 'agent-preset/not-found' }) }) it('rejects an empty Agent preset id before resolving a provider', async () => { @@ -405,23 +395,20 @@ describe('the settings Remote namespace a configuration page calls', () => { const controller = new SettingsController(ctx) await expect(controller.openAgentPresetDirectory('', new AbortController().signal)) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) expect(resolve).not.toHaveBeenCalled() }) - it.each([ - [new UnknownPresetError('missing', ['standard']), 'agent-preset-not-found'], - [new InvalidPresetIdError('../bad'), 'agent-preset-invalid'], - [new PresetExistsError('taken'), 'agent-preset-invalid'], - [new TypertRemoteFailure({ code: 'cancelled', message: 'cancelled', details: {} }), 'cancelled'], - ['unexpected preset failure', 'internal'], - ] as const)('maps Agent preset resolution failure %#', async (error, code) => { + it('raises an Agent preset resolution failure as the roster reported it', async () => { const ctx = new Context() - ctx.provide('agentPresets', { resolve: async () => { throw error } } as never) + const reported = new RemoteError('agent-preset/not-found', 'no such preset', { + agentPreset: 'mine', available: ['standard'], + }) + ctx.provide('agentPresets', { resolve: async () => { throw reported } } as never) const controller = new SettingsController(ctx) await expect(controller.openAgentPresetDirectory('mine', new AbortController().signal)) - .rejects.toMatchObject({ failure: { code } }) + .rejects.toBe(reported) }) it('classifies cancellation and non-Error failures from the preset opener', async () => { @@ -441,10 +428,8 @@ describe('the settings Remote namespace a configuration page calls', () => { const controller = new SettingsController(ctx, { nativeOpen: true }, { openPath }) await expect(controller.openAgentPresetDirectory('first', abort.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) await expect(controller.openAgentPresetDirectory('second', new AbortController().signal)) - .rejects.toMatchObject({ - failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, - }) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: desktop unavailable' }) }) }) diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index 0e2c3c5fa7..fffa05c18f 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -7,7 +7,7 @@ import { type ClientRemote, } from '@deepseek-ai/dsh-api-gateway/client' import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from '../types.ts' -import type { WorkspaceFollowSink, WorkspaceRemote } from './model.ts' +import type { WorkspaceFollowSink } from './model.ts' import { ClientWorkspaceModel } from './model.ts' import { WorkspaceController } from './service.ts' @@ -19,10 +19,6 @@ export { WorkspaceController, WorkspaceCreateError } from './service.ts' export type { IWorkspaces, WorkspaceSource } from './service.ts' export type { WorkspaceId, WorkspaceView } from '../types.ts' -type WorkspaceStreamRemote = Pick & { - readonly workspace: WorkspaceRemote -} - type WorkspaceBaselineFrame = Extract /** Gateway-owned snapshot stream configured for Workspace state. */ @@ -46,10 +42,9 @@ export const inject = ['remote', 'remote.workspace'] * @param ctx - Client root Context. */ export function apply(ctx: Context): void { - const remote = ctx.remote as WorkspaceStreamRemote - const model = new ClientWorkspaceModel(remote.workspace) + const model = new ClientWorkspaceModel(ctx.remote.workspace) new WorkspaceController(ctx, model) - const control = createWorkspaceStateStream(remote, { + const control = createWorkspaceStateStream(ctx.remote, { accept: model, carrierFailed: () => { model.handleCarrierFailure() }, failed: (error) => { model.handleStreamFailure(error) }, @@ -73,12 +68,12 @@ export interface WorkspaceStateStreamOptions { /** * Create the reconnecting Workspace state stream. - * @param remote - generated Workspace namespace and Gateway stream factory. + * @param remote - Client Remote face carrying the Workspace namespace and the stream factory. * @param options - Workspace state destinations. * @returns an unstarted stream owned by the Client Workspace runtime. */ export function createWorkspaceStateStream( - remote: WorkspaceStreamRemote, + remote: ClientRemote, options: WorkspaceStateStreamOptions, ): WorkspaceStateStream { const stream = remote.$stream({ diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index 2ec365e8b2..326ff7031a 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -2,6 +2,7 @@ import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' import type { RemoteFailure, RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' import type { WorkspaceArchiveSessionRequest, @@ -82,12 +83,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { * @returns generated Remote result. */ async create(input: WorkspaceCreateRequest): Promise> { - let result: RemoteResult - try { - result = await this.remote.create(input) - } catch (error) { - result = failureResult(error) - } + const result = await this.remote.create(input) if (result.ok) this.upsert(result.value.workspace) return result } @@ -129,19 +125,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { const frameGeneration = this.orderFrameGeneration const localOrder = this.items.map(workspace => workspace.workspaceId) this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId)) - let result: RemoteResult - try { - result = await this.remote.insertBefore({ - workspaceId, - ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, - }) - } catch (error) { - if (requestGeneration === this.orderRequestGeneration - && frameGeneration === this.orderFrameGeneration) { - this.installOrder(this.committedOrder) - } - throw error - } + const result = await this.remote.insertBefore({ + workspaceId, + ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, + }) if (requestGeneration === this.orderRequestGeneration && frameGeneration === this.orderFrameGeneration) { this.installOrder(result.ok ? result.value.workspaceIds : this.committedOrder, result.ok) @@ -233,8 +220,9 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { * @param error - terminal stream failure. */ handleStreamFailure(error: unknown): void { + if (!isRemoteFailure(error)) throw error this.state = 'error' - this.error = failureOf(error) + this.error = error this.invalidate() } @@ -369,15 +357,3 @@ function insertIdBefore( const at = beforeId === undefined ? without.length : without.indexOf(beforeId) return [...without.slice(0, at), id, ...without.slice(at)] } - -function failureResult(error: unknown): RemoteResult { - return { ok: false, error: failureOf(error) } -} - -function failureOf(error: unknown): RemoteFailure { - return { - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - } -} diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index a8511ac61e..3cfd42ce0a 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -11,7 +11,7 @@ import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts' export class WorkspaceCreateError extends Error { override readonly name = 'WorkspaceCreateError' - /** @param rpcError - Host business or folded transport failure. */ + /** @param rpcError - Host business or folded carrier failure. */ constructor(readonly rpcError: RemoteFailure) { super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`) } diff --git a/packages/api/workspace-controller/src/commands.ts b/packages/api/workspace-controller/src/commands.ts index 0bb36b0897..af48cb82ad 100644 --- a/packages/api/workspace-controller/src/commands.ts +++ b/packages/api/workspace-controller/src/commands.ts @@ -8,7 +8,7 @@ import { WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import { workspaceView } from './feed.ts' import type { WorkspaceArchiveSessionRequest, @@ -46,11 +46,12 @@ export class WorkspaceCommands { const workspace = await this.ctx.workspaceRegistry.create(request.path) return { workspace: workspaceView(workspace), created: true } } catch (error) { - if (error instanceof TypertRemoteFailure) throw error - throw failure( - 'workspace-invalid-path', + if (remoteErrorOf(error) !== undefined) throw error + throw new RemoteError( + 'workspace/invalid-path', `cannot create a Workspace at "${request.path}": ${errorMessage(error)}`, { path: request.path }, + { cause: error }, ) } }) @@ -64,19 +65,15 @@ export class WorkspaceCommands { rename(request: WorkspaceRenameRequest): Promise { const title = request.title.trim() if (title === '') { - return Promise.reject(failure( - 'bad-request', - 'Workspace rename requires a non-blank title', - {}, - )) + return Promise.reject(new RemoteError('gateway/bad-request', 'Workspace rename requires a non-blank title', {})) } return this.enqueue(async () => { const workspace = this.requireWorkspace(request.workspaceId) if (title !== workspace.title) { if (this.ctx.workspaceRegistry.list().some(candidate => candidate.id !== workspace.id && candidate.title === title)) { - throw failure( - 'workspace-name-conflict', + throw new RemoteError( + 'workspace/name-conflict', `Workspace name '${title}' is already in use`, { name: title }, ) @@ -132,8 +129,8 @@ export class WorkspaceCommands { await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId) } catch (error) { if (!(error instanceof WorkspaceMoveInvalidError)) throw error - throw failure( - 'workspace-move-invalid', + throw new RemoteError( + 'workspace/move-invalid', error.message, { workspaceId: request.workspaceId, @@ -142,6 +139,7 @@ export class WorkspaceCommands { ? {} : { beforeSessionId: request.beforeSessionId }, }, + { cause: error }, ) } return { workspace: workspaceView(workspace) } @@ -157,7 +155,7 @@ export class WorkspaceCommands { await this.ctx.workspaceRegistry.archiveSession(request.sessionId) } catch (error) { if (!(error instanceof WorkspaceUnknownSessionError)) throw error - throw failure('session-not-found', error.message, { sessionId: request.sessionId }) + throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error }) } return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] } } @@ -175,22 +173,14 @@ export class WorkspaceCommands { } } -function workspaceNotFound(workspaceId: WorkspaceId): TypertRemoteFailure { - return failure( - 'workspace-not-found', +function workspaceNotFound(workspaceId: WorkspaceId): RemoteError<'workspace/not-found'> { + return new RemoteError( + 'workspace/not-found', `Workspace "${workspaceId}" not found`, { workspaceId }, ) } -function failure( - code: string, - message: string, - details: object, -): TypertRemoteFailure { - return new TypertRemoteFailure({ code, message, details }) -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/packages/api/workspace-controller/src/directory-picker.ts b/packages/api/workspace-controller/src/directory-picker.ts index ee58ef7b83..41f7db97f4 100644 --- a/packages/api/workspace-controller/src/directory-picker.ts +++ b/packages/api/workspace-controller/src/directory-picker.ts @@ -6,12 +6,14 @@ import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' -import type { DirectoryPickerCapabilities } from '@deepseek-ai/dsh-host-directory-picker' +import type { + DirectoryPickerCapabilities, DirectoryPickerErrorCode, +} from '@deepseek-ai/dsh-host-directory-picker' // The seam owns the listing declaration; the generator requires the reference // site to name that package rather than this package's re-export of it. import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import type { DirectoryPickerErrorDetailsMap } from './types.ts' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteErrorCode } from '@deepseek-ai/dsh-typert-protocol' const createDirectoryRequestSchema = z.object({ path: z.string(), @@ -86,8 +88,8 @@ export class DirectoryPickerController extends TypertRemoteService { async createDirectory(path: string, name: string): Promise { const request = createDirectoryRequestSchema.safeParse({ path, name }) if (!request.success) { - throw pickerFailureOf( - 'bad-request', + throw new RemoteError( + 'gateway/bad-request', 'invalid payload for host.createDirectory', { issues: request.error.issues }, ) @@ -107,8 +109,8 @@ export class DirectoryPickerController extends TypertRemoteService { ): DirectoryPickerCapabilities[Kind] { const capability = this.ctx.directoryPicker.capability() if (capability.kind !== kind) { - throw pickerFailureOf( - 'directory-picker-unavailable', + throw new RemoteError( + 'directory-picker/unavailable', `directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`, { capability: capability.kind }, ) @@ -118,19 +120,15 @@ export class DirectoryPickerController extends TypertRemoteService { } /** - * Raise one entry of the picking wire failure vocabulary. - * @param code - the failure code a caller discriminates on. - * @param message - operator-facing description. - * @param details - the payload this code carries. - * @returns the failure to throw across the Remote boundary. + * Wire code answered for each seam browse failure. The seam's closed codes are + * its own local vocabulary, so this controller owns the projection onto the + * `directory-picker/*` codes a Remote caller discriminates on. */ -function pickerFailureOf( - code: Code, - message: string, - details: DirectoryPickerErrorDetailsMap[Code], -): TypertRemoteFailure { - return new TypertRemoteFailure({ code, message, details }) -} +const BROWSE_FAILURE_CODES = { + 'directory-unreadable': 'directory-picker/unreadable', + 'directory-exists': 'directory-picker/exists', + 'directory-create-failed': 'directory-picker/create-failed', +} as const satisfies Record /** * Classify a browse-primitive rejection: the seam's own closed codes carry the @@ -138,16 +136,21 @@ function pickerFailureOf( * @param error - the primitive's rejection. * @returns the failure to throw across the Remote boundary. */ -function browseFailure(error: unknown): TypertRemoteFailure { +function browseFailure(error: unknown): RemoteError { if (error instanceof DirectoryPickerError) { - return pickerFailureOf(error.code, error.message, { path: error.path }) + return new RemoteError( + BROWSE_FAILURE_CODES[error.code], + error.message, + { path: error.path }, + { cause: error }, + ) } - return pickerFailureOf('internal', errorMessage(error), {}) + return new RemoteError('gateway/internal', errorMessage(error), {}, { cause: error }) } /** * Classify a cancellable primitive's rejection. An abort is the caller's own - * timeout or disconnect, not a backend failure, so it answers `cancelled` + * timeout or disconnect, not a backend failure, so it answers `gateway/cancelled` * before the business classification runs. * @param error - the primitive's rejection. * @param signal - the caller lifetime the primitive ran under. @@ -160,10 +163,10 @@ function cancellableFailure( signal: AbortSignal, cancelled: string, failed?: string, -): TypertRemoteFailure { - if (signal.aborted) return pickerFailureOf('cancelled', cancelled, {}) +): RemoteError { + if (signal.aborted) return new RemoteError('gateway/cancelled', cancelled, {}, { cause: error }) if (failed === undefined) return browseFailure(error) - return pickerFailureOf('internal', `${failed}: ${errorMessage(error)}`, {}) + return new RemoteError('gateway/internal', `${failed}: ${errorMessage(error)}`, {}, { cause: error }) } function errorMessage(error: unknown): string { diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index 552a9e20c3..aa053c8d67 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -7,9 +7,6 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' -import type { z as zCore } from 'zod' - -type ZodIssue = zCore.core.$ZodIssue export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' @@ -29,45 +26,27 @@ export interface WorkspaceView { readonly updatedAt: string } -/** Stable Workspace failure details returned by unary methods. */ -export interface WorkspaceErrorDetailsMap { - 'bad-request': Record - 'workspace-invalid-path': { readonly path: string } - 'workspace-not-found': { readonly workspaceId: WorkspaceId } - 'workspace-name-conflict': { readonly name: string } - 'workspace-move-invalid': { - readonly workspaceId: WorkspaceId - readonly sessionId: SessionId - readonly beforeSessionId?: SessionId +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** The requested directory cannot back a Workspace. */ + 'workspace/invalid-path': { readonly path: string } + /** Another Workspace already uses the requested name. */ + 'workspace/name-conflict': { readonly name: string } + /** The Session or its anchor is not in the Workspace's manual order. */ + 'workspace/move-invalid': { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId + } + /** The verb needs an interaction the composed backend does not serve. */ + 'directory-picker/unavailable': { readonly capability: string } + /** The target is not fully qualified, or the backend cannot list it. */ + 'directory-picker/unreadable': { readonly path: string } + /** A child of that name is already there. */ + 'directory-picker/exists': { readonly path: string } + /** The parent is not fully qualified, the name is not one segment, or creation failed. */ + 'directory-picker/create-failed': { readonly path: string } } - 'session-not-found': { readonly sessionId: SessionId } -} - -/** Workspace business failure returned without throwing a carrier error. */ -export type WorkspaceError = { - [Code in keyof WorkspaceErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: WorkspaceErrorDetailsMap[Code] - } -}[keyof WorkspaceErrorDetailsMap] - -/** Stable directory-picking failure details returned by the picking wire verbs. */ -export interface DirectoryPickerErrorDetailsMap { - /** The directory creation request violates its semantic input constraints. */ - 'bad-request': { readonly issues: ZodIssue[] } - /** The verb needs an interaction the composed backend does not serve. */ - 'directory-picker-unavailable': { readonly capability: string } - /** The target is not fully qualified, or the backend cannot list it. */ - 'directory-unreadable': { readonly path: string } - /** A child of that name is already there. */ - 'directory-exists': { readonly path: string } - /** The parent is not fully qualified, the name is not one segment, or creation failed. */ - 'directory-create-failed': { readonly path: string } - /** The caller's own timeout or disconnect ended the chooser or the scan. */ - cancelled: Record - /** A backend failure with no seam code of its own. */ - internal: Record } /** Existing directory requested for Workspace adoption. */ diff --git a/packages/api/workspace-controller/tests/directory-picker.host.spec.ts b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts index 32fd2dd6a1..307de96dc4 100644 --- a/packages/api/workspace-controller/tests/directory-picker.host.spec.ts +++ b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import { DirectoryPickerController } from '../src/directory-picker.ts' const roots: Context[] = [] @@ -60,8 +60,9 @@ async function refused(call: Promise): Promise<{ code: string; message: try { await call } catch (error: unknown) { - if (!(error instanceof TypertRemoteFailure)) throw error - return { ...error.failure } + const failure = remoteErrorOf(error) + if (failure === undefined) throw error + return { code: failure.code, message: failure.message, details: failure.details } } throw new Error('the call was expected to be refused') } @@ -85,18 +86,18 @@ describe('directoryPicker pick Remote', () => { const abort = new AbortController() const pending = refused(picker.pick(abort.signal)) abort.abort() - expect((await pending).code).toBe('cancelled') + expect((await pending).code).toBe('gateway/cancelled') const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } }) const failure = await refused(broken.pick(new AbortController().signal)) - expect(failure.code).toBe('internal') + expect(failure.code).toBe('gateway/internal') expect(failure.message).toContain('no chooser installed') }) it('refuses the native verb under a browse composition', async () => { const picker = await harness(BROWSE_STUB) const failure = await refused(picker.pick(new AbortController().signal)) - expect(failure.code).toBe('directory-picker-unavailable') + expect(failure.code).toBe('directory-picker/unavailable') expect(failure.message).toContain('needs the native capability') expect(failure.details).toEqual({ capability: 'browse' }) }) @@ -115,12 +116,12 @@ describe('directoryPicker browse Remotes', () => { it('maps the seam\'s typed failures and folds unknown throws to internal', async () => { const picker = await harness(BROWSE_STUB) expect(await refused(picker.list('/denied', new AbortController().signal))) - .toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } }) - expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists') - expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal') + .toMatchObject({ code: 'directory-picker/unreadable', details: { path: '/denied' } }) + expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-picker/exists') + expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('gateway/internal') const thrown = await refused(picker.createDirectory('/home/user', 'gone')) - expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' }) + expect(thrown).toMatchObject({ code: 'gateway/internal', message: 'the volume vanished' }) }) it('rejects invalid child names before capability dispatch', async () => { @@ -134,7 +135,7 @@ describe('directoryPicker browse Remotes', () => { for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { const failure = await refused(picker.createDirectory('/home/user', name)) expect(failure).toMatchObject({ - code: 'bad-request', + code: 'gateway/bad-request', message: 'invalid payload for host.createDirectory', }) expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true) @@ -153,14 +154,14 @@ describe('directoryPicker browse Remotes', () => { const abort = new AbortController() const pending = refused(picker.list(undefined, abort.signal)) abort.abort() - expect((await pending).code).toBe('cancelled') + expect((await pending).code).toBe('gateway/cancelled') }) it('refuses the browse verbs under a native composition', async () => { const picker = await harness() expect(await refused(picker.list(undefined, new AbortController().signal))) - .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + .toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } }) expect(await refused(picker.createDirectory('/x', 'y'))) - .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + .toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } }) }) }) diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index 572f5b8f9c..3f097b4116 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -15,11 +15,10 @@ import type { WorkspaceOrderValue, WorkspaceRenameRequest, WorkspaceValue, - WorkspaceError, WorkspaceId, WorkspaceView, } from '../src/types.ts' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' const sid = (id: string): SessionId => id as SessionId @@ -44,7 +43,7 @@ function remoteOk(value: T): RemoteResult { return { ok: true, value } } -function workspaceError(error: WorkspaceError): RemoteResult { +function workspaceError(error: RemoteFailure): RemoteResult { return { ok: false, error } } @@ -158,17 +157,17 @@ describe('ClientWorkspaceModel', () => { model.handleCarrierFailure() expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'loading', error: null }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['visible']) - model.handleStreamFailure(new Error('wire down')) + model.handleStreamFailure(new RemoteError('gateway/internal', 'wire down', {})) expect(model.getSnapshot()).toMatchObject({ - phase: 'ready', state: 'error', error: { code: 'internal', message: 'wire down' }, + phase: 'ready', state: 'error', error: { code: 'gateway/internal', message: 'wire down' }, }) - model.handleStreamFailure('plain failure') - expect(model.getSnapshot().error?.message).toBe('plain failure') + // An unmarked value never crosses the stream boundary: it is a local fault. + expect(() => { model.handleStreamFailure('plain failure') }).toThrow() baseline(model, [workspace('restored')]) expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null }) }) - it('creates by path, prepends the returned row, and folds rejected calls', async () => { + it('creates by path and prepends the returned row', async () => { const remote = new FakeWorkspaceRemote() const model = modelFor(remote) remote.onCreate = request => Promise.resolve(remoteOk({ @@ -178,11 +177,6 @@ describe('ClientWorkspaceModel', () => { await expect(model.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) expect(remote.calls).toContainEqual({ method: 'create', request: { path: '/w/created' } }) expect(model.getSnapshot().items[0]?.workspaceId).toBe('created') - - remote.onCreate = () => Promise.reject(new Error('create transport')) - await expect(model.create({ path: '/w/existing' })).resolves.toMatchObject({ - ok: false, error: { code: 'internal', message: 'create transport' }, - }) }) it('lets newer stream order outrank unary echoes and rolls failures back', async () => { @@ -199,22 +193,16 @@ describe('ClientWorkspaceModel', () => { await pending expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) - remote.onInsertBefore = () => Promise.resolve(workspaceError({ - code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('three') }, - })) + remote.onInsertBefore = () => Promise.resolve(workspaceError( + new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('three') }), + )) const rejected = model.insertBefore(wid('three')) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) await expect(rejected).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) - - remote.onInsertBefore = () => Promise.reject(new Error('transport down')) - const disconnected = model.insertBefore(wid('three'), wid('one')) - expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) - await expect(disconnected).rejects.toThrow('transport down') - expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) }) - it('keeps a newer optimistic reorder when an older transport call rejects', async () => { + it('keeps a newer optimistic reorder when an older refused call settles', async () => { const remote = new FakeWorkspaceRemote() const model = modelFor(remote) baseline(model, [workspace('one'), workspace('two'), workspace('three')]) @@ -225,8 +213,10 @@ describe('ClientWorkspaceModel', () => { const first = model.insertBefore(wid('three'), wid('one')) const second = model.insertBefore(wid('two'), wid('three')) - firstGate.reject(new Error('first transport failed')) - await expect(first).rejects.toThrow('first transport failed') + firstGate.resolve(workspaceError( + new RemoteError('workspace/not-found', 'first refused', { workspaceId: wid('three') }), + )) + await expect(first).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) secondGate.resolve(remoteOk({ workspaceIds: [wid('two'), wid('three'), wid('one')] })) await expect(second).resolves.toMatchObject({ ok: true }) @@ -244,14 +234,10 @@ describe('ClientWorkspaceModel', () => { const first = model.insertBefore(wid('three'), wid('one')) const second = model.insertBefore(wid('two'), wid('three')) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) - firstGate.resolve(workspaceError({ - code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: wid('three') }, - })) + firstGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'first rejected', { workspaceId: wid('three') }))) await expect(first).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) - secondGate.resolve(workspaceError({ - code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: wid('two') }, - })) + secondGate.resolve(workspaceError(new RemoteError('workspace/not-found', 'second rejected', { workspaceId: wid('two') }))) await expect(second).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) }) @@ -284,15 +270,11 @@ describe('ClientWorkspaceModel', () => { const model = modelFor(remote) baseline(model, [workspace('one', [sid('first'), sid('second')])], [sid('archived')]) - remote.onRename = () => Promise.resolve(workspaceError({ - code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') }, - })) + remote.onRename = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') }))) await expect(model.rename(wid('one'), 'ignored')).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items[0]?.title).toBe('one') - remote.onDelete = () => Promise.resolve(workspaceError({ - code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') }, - })) + remote.onDelete = () => Promise.resolve(workspaceError(new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('one') }))) await expect(model.delete(wid('one'))).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().items).toHaveLength(1) @@ -306,11 +288,9 @@ describe('ClientWorkspaceModel', () => { request: { workspaceId: 'one', sessionId: 'second', beforeSessionId: 'first' }, }) - remote.onInsertSessionBefore = () => Promise.resolve(workspaceError({ - code: 'workspace-move-invalid', - message: 'invalid move', - details: { workspaceId: wid('one'), sessionId: sid('second') }, - })) + remote.onInsertSessionBefore = () => Promise.resolve(workspaceError( + new RemoteError('workspace/move-invalid', 'invalid move', { workspaceId: wid('one'), sessionId: sid('second') }), + )) await expect(model.insertSessionBefore(wid('one'), sid('second'))) .resolves.toMatchObject({ ok: false }) expect(remote.calls).toContainEqual({ @@ -318,9 +298,9 @@ describe('ClientWorkspaceModel', () => { request: { workspaceId: 'one', sessionId: 'second' }, }) - remote.onArchiveSession = () => Promise.resolve(workspaceError({ - code: 'session-not-found', message: 'missing', details: { sessionId: sid('missing') }, - })) + remote.onArchiveSession = () => Promise.resolve(workspaceError( + new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }), + )) await expect(model.archiveSession(sid('missing'))).resolves.toMatchObject({ ok: false }) expect(model.getSnapshot().archivedSessionIds).toEqual(['archived']) remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] })) diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index bdd01d33ea..52d7a21020 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -3,11 +3,12 @@ import { describe, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, + type ClientRemote, type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import * as WorkspaceClientPlugin from '../src/client/index.ts' import { ClientWorkspaceModel, @@ -29,7 +30,6 @@ import type { WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, WorkspaceRenameRequest, - WorkspaceError, WorkspaceId, WorkspaceValue, WorkspaceView, @@ -45,11 +45,11 @@ const AVAILABLE_CONNECTION = { function workspaceClient( remote: WorkspaceRemote, connection: Pick = AVAILABLE_CONNECTION, -) { +): ClientRemote { return { workspace: remote, $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), - } + } as unknown as ClientRemote } interface Generation { @@ -94,7 +94,7 @@ function remoteOk(value: T): RemoteResult { return { ok: true, value } } -function remoteFailure(error: WorkspaceError): RemoteResult { +function remoteFailure(error: RemoteFailure): RemoteResult { return { ok: false, error } } @@ -250,7 +250,7 @@ describe('Workspace Controller Client apply', () => { phase: 'ready', state: 'error', items: [{ workspaceId: 'fresh' }], - error: { code: 'internal', message: 'Workspace state stream emitted more than one opening snapshot' }, + error: { code: 'gateway/internal', message: 'Workspace state stream emitted more than one opening snapshot' }, }) }) @@ -445,40 +445,27 @@ describe('WorkspaceController', () => { it('maps generated business failures to the command facade errors', async () => { const remote = new CommandWorkspaceRemote() const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote)) - const missingWorkspace: WorkspaceError = { - code: 'workspace-not-found', - message: 'gone', - details: { workspaceId: wid('missing') }, - } - const missingSession: WorkspaceError = { - code: 'session-not-found', - message: 'missing session', - details: { sessionId: sid('session') }, - } + const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') }) + const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') }) - remote.create.mockResolvedValueOnce(remoteFailure({ - code: 'workspace-invalid-path', - message: 'missing path', - details: { path: '/missing' }, - })) + remote.create.mockResolvedValueOnce(remoteFailure(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) const create = controller.create({ path: '/missing' }) await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) - await expect(create).rejects.toThrow('workspace-invalid-path: missing path') + await expect(create).rejects.toThrow('workspace/invalid-path: missing path') remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace)) - await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace-not-found: gone') + await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace/not-found: gone') remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace)) - await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace-not-found: gone') + await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace/not-found: gone') remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace)) - await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace-not-found: gone') + await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace/not-found: gone') remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession)) - await expect(controller.archiveSession(sid('session'))).rejects.toThrow('workspace session archive failed: session-not-found: missing session') - remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure({ - code: 'workspace-move-invalid', - message: 'invalid move', - details: { workspaceId: wid('missing'), sessionId: sid('session') }, - })) + await expect(controller.archiveSession(sid('session'))) + .rejects.toThrow('workspace session archive failed: session/not-found: missing session') + remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure(new RemoteError( + 'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') }, + ))) await expect(controller.insertSessionBefore(wid('missing'), sid('session'))) - .rejects.toThrow('workspace move failed: workspace-move-invalid: invalid move') + .rejects.toThrow('workspace move failed: workspace/move-invalid: invalid move') }) }) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 88e2e65964..dab997847b 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import WorkspaceController from '../src/index.ts' @@ -14,6 +14,12 @@ import { WorkspaceFeed } from '../src/feed.ts' import type { WorkspaceFollowFrame } from '../src/types.ts' import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'fixture/failure': {} + } +} + const roots: Context[] = [] afterEach(async () => { @@ -94,34 +100,29 @@ describe('WorkspaceController commands', () => { const second = await controller.create({ path: stageDir(root, 'second') }) await expect(controller.create({ path: join(root, 'missing') })).rejects.toMatchObject({ - failure: { code: 'workspace-invalid-path', details: { path: join(root, 'missing') } }, + code: 'workspace/invalid-path', + details: { path: join(root, 'missing') }, }) expect(existsSync(join(root, 'missing'))).toBe(false) await expect(controller.rename({ workspaceId: first.workspace.workspaceId, title: ' ' })) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'occupied' }) await expect(controller.rename({ workspaceId: second.workspace.workspaceId, title: ' occupied ' })) - .rejects.toMatchObject({ failure: { code: 'workspace-name-conflict' } }) + .rejects.toMatchObject({ code: 'workspace/name-conflict' }) await expect(controller.delete({ workspaceId: 'missing' as WorkspaceId })) - .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + .rejects.toMatchObject({ code: 'workspace/not-found' }) }) it('preserves Remote failures and propagates unexpected registry failures', async () => { const { controller, ctx, root } = await harness() - const remoteFailure = new TypertRemoteFailure({ - code: 'fixture-failure', - message: 'already mapped', - details: {}, - }) + const remoteFailure = new RemoteError('fixture/failure', 'already mapped', {}) const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath') .mockRejectedValueOnce(remoteFailure) .mockRejectedValueOnce('plain failure') await expect(controller.create({ path: stageDir(root, 'remote-failure') })) .rejects.toBe(remoteFailure) const plainFailure = controller.create({ path: stageDir(root, 'plain-failure') }) - await expect(plainFailure).rejects.toMatchObject({ - failure: { code: 'workspace-invalid-path' }, - }) + await expect(plainFailure).rejects.toMatchObject({ code: 'workspace/invalid-path' }) await expect(plainFailure).rejects.toThrow('plain failure') resolveByPath.mockRestore() @@ -168,7 +169,7 @@ describe('WorkspaceController commands', () => { gate.resolve(undefined) await blocker await expect(deletion).resolves.toEqual({ deleted: true }) - await expect(staleRename).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + await expect(staleRename).rejects.toMatchObject({ code: 'workspace/not-found' }) }) it('reorders Workspaces and Sessions and archives only known Sessions', async () => { @@ -182,7 +183,7 @@ describe('WorkspaceController commands', () => { workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId], }) await expect(controller.insertBefore({ workspaceId: 'missing' as WorkspaceId })) - .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + .rejects.toMatchObject({ code: 'workspace/not-found' }) const session = ctx.sessions.create(SessionId('session-one'), { meta: { cwd: first.workspace.path }, @@ -197,26 +198,24 @@ describe('WorkspaceController commands', () => { await expect(controller.insertSessionBefore({ workspaceId: first.workspace.workspaceId, sessionId: SessionId('missing-session'), - })).rejects.toMatchObject({ failure: { code: 'workspace-move-invalid' } }) + })).rejects.toMatchObject({ code: 'workspace/move-invalid' }) await expect(controller.insertSessionBefore({ workspaceId: first.workspace.workspaceId, sessionId: session.id, beforeSessionId: SessionId('missing-anchor'), })).rejects.toMatchObject({ - failure: { - code: 'workspace-move-invalid', - details: { beforeSessionId: 'missing-anchor' }, - }, + code: 'workspace/move-invalid', + details: { beforeSessionId: 'missing-anchor' }, }) await expect(controller.insertSessionBefore({ workspaceId: 'missing' as WorkspaceId, sessionId: session.id, - })).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + })).rejects.toMatchObject({ code: 'workspace/not-found' }) await expect(controller.archiveSession({ sessionId: session.id })) .resolves.toEqual({ archivedSessionIds: [session.id] }) await expect(controller.archiveSession({ sessionId: SessionId('unknown') })) - .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + .rejects.toMatchObject({ code: 'session/not-found' }) }) }) diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 44e7595f34..41e384c662 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -2,8 +2,6 @@ export type { ClientRequest, - RpcError, - RpcErrorCode, RpcMessage, RpcRequest, RpcResponse, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 17b3a006c4..70f7cbf8fc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1806,7 +1806,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'settings-rejected', + code: 'settings/rejected', message: 'fixture: the minimal readiness settings descriptor is read-only', details: { ns }, }, @@ -1816,7 +1816,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'settings-rejected', + code: 'settings/rejected', message: 'fixture: the minimal readiness settings descriptor is read-only', details: { ns }, }, @@ -1827,7 +1827,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'settings-rejected', + code: 'settings/rejected', message: 'fixture: no settings namespaces are registered', details: { ns }, }, @@ -1844,7 +1844,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-read-only', + code: 'agent-preset/read-only', message: `agent preset "${agentPreset}" ships with the deployment`, details: { agentPreset, reason: 'it ships with the deployment' }, }, @@ -2026,7 +2026,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ): Promise> | undefined => { if (summaryOf(request.sessionId) !== undefined) return undefined return sessionErr({ - code: 'session-not-found', + code: 'session/not-found', message: `no session ${request.sessionId}`, details: { sessionId: request.sessionId }, }) @@ -2079,12 +2079,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const goalFailure = (message: string): RpcResult => ({ ok: false, - error: { code: 'internal', message, details: {} }, + error: { code: 'gateway/internal', message, details: {} }, }) const requireGoalSession = (id: SessionId): RpcResult | undefined => ( summaryOf(id) === undefined - ? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } } + ? { ok: false, error: { code: 'session/not-found', message: `no session ${id}`, details: { sessionId: id } } } : undefined ) @@ -2273,7 +2273,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (children === undefined) { return { ok: false, - error: { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }, + error: { code: 'directory-picker/unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }, } } return { @@ -2292,13 +2292,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { createDirectory(parent: string, name: string): ConnectionRpcResult { const children = childrenOf(parent) if (children === undefined) { - return { ok: false, error: { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } } } + return { ok: false, error: { code: 'directory-picker/create-failed', message: `missing parent ${parent}`, details: { path: parent } } } } // Same root special case as list's entry paths: a plain join under '/' // would mint '//name' and fork the tree's identity. const target = parent === '/' ? `/${name}` : `${parent}/${name}` if (children.includes(name)) { - return { ok: false, error: { code: 'directory-exists', message: `${target} already exists`, details: { path: target } } } + return { ok: false, error: { code: 'directory-picker/exists', message: `${target} already exists`, details: { path: target } } } } directoryTree.set(parent, [...children, name]) directoryTree.set(target, []) @@ -2428,7 +2428,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', message: `unknown agent preset "${agentPreset}"`, details: { agentPreset, available: [...fixturePresets.keys()] }, }, @@ -2442,7 +2442,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', message: `unknown agent preset "${from}"`, details: { agentPreset: from, available: [...fixturePresets.keys()] }, }, @@ -2452,7 +2452,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-invalid', + code: 'agent-preset/invalid', message: `agent preset "${id}" already exists`, details: { agentPreset: id, reason: 'already exists' }, }, @@ -2466,7 +2466,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'agent-preset-read-only', + code: 'agent-preset/read-only', message: `agent preset "${id}" ships with the deployment`, details: { agentPreset: id, reason: 'it ships with the deployment' }, }, @@ -2724,7 +2724,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { search: (request, signal) => { if (signal.aborted) { return sessionErr({ - code: 'cancelled', + code: 'gateway/cancelled', message: 'fixture session search was aborted', details: {}, }) @@ -2766,7 +2766,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { : workspaces.find(w => w.workspaceId === request.workspaceId) if (request.workspaceId !== undefined && workspace === undefined) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId }, }) @@ -2784,7 +2784,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { sessionId: SessionId, workspaceId: WorkspaceId, ): Promise> => sessionErr({ - code: 'workspace-attach-failed' as const, + code: 'session/workspace-attach-failed' as const, message: `fixture rejected Workspace attachment for ${sessionId}`, details: { sessionId, workspaceId }, }) @@ -2793,7 +2793,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (existing !== undefined) { if (existing.cwd !== cwd) { return sessionErr({ - code: 'session-conflict', + code: 'session/conflict', message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, }) @@ -2834,7 +2834,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const normalized = title.trim().replace(/\s+/g, ' ') if (normalized.length === 0) { return sessionErr({ - code: 'title-invalid', + code: 'session/title-invalid', message: 'session title must contain visible characters', details: { sessionId }, }) @@ -2853,7 +2853,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const source = summaryOf(sessionId) if (source === undefined) { return sessionErr({ - code: 'session-not-found', + code: 'session/not-found', message: `no session ${sessionId}`, details: { sessionId }, }) @@ -2869,7 +2869,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { : undefined) if (boundary === undefined) { return sessionErr({ - code: 'fork-unavailable', + code: 'session/fork-unavailable', message: atSeq !== undefined && atSeq <= lastSeq ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` : `session ${sessionId} has no completed turn`, @@ -2923,18 +2923,18 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const { sessionId: id, mode, content } = request const summary = summaryOf(id) if (summary === undefined) { - return sessionErr({ code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) + return sessionErr({ code: 'session/not-found', message: `no session ${id}`, details: { sessionId: id } }) } if (options.rejectPrompt) { if (content.some(block => block.type === 'image')) { return sessionErr({ - code: 'attachment-error', + code: 'session/attachment-invalid', message: 'fixture: image side exceeds the deployment limit', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, }) } return sessionErr({ - code: 'agent-busy', + code: 'session/agent-busy', message: 'fixture: prompt rejected before acceptance', details: { reason: 'fixture-prompt-rejection' }, }) @@ -3029,7 +3029,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const stored = attachments.get(String(request.attachmentId)) if (stored === undefined) { return sessionErr({ - code: 'attachment-error', + code: 'session/attachment-invalid', message: 'fixture attachment missing', details: { reason: 'ATTACHMENT_NOT_FOUND' }, }) @@ -3039,7 +3039,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { String(request.attachmentId), )) { return sessionErr({ - code: 'attachment-error', + code: 'session/attachment-invalid', message: 'fixture attachment is not referenced by this session', details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, }) @@ -3047,7 +3047,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return sessionOk(stored) }, updateQueue: request => sessionErr({ - code: 'queue-item-not-found', + code: 'session/queue-item-not-found', message: 'fixture has no pending queue item', details: { itemId: request.itemId }, }), @@ -3226,7 +3226,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: false, error: { - code: 'invocation-unavailable', + code: 'gateway/invocation-unavailable', message: 'fixture Remote event result identifies no active event stream', details: {}, }, @@ -3269,7 +3269,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) if (workspace === undefined) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId }, }) @@ -3277,7 +3277,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const title = request.title.trim() if (title === '') { return sessionErr({ - code: 'bad-request', + code: 'gateway/bad-request', message: 'Workspace rename requires a non-blank title', details: {}, }) @@ -3285,7 +3285,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (title !== workspace.title) { if (workspaces.some(candidate => candidate.workspaceId !== request.workspaceId && candidate.title === title)) { return sessionErr({ - code: 'workspace-name-conflict', + code: 'workspace/name-conflict', message: `workspace name '${title}' is already in use`, details: { name: title }, }) @@ -3300,7 +3300,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const index = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) if (index === -1) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId }, }) @@ -3321,7 +3321,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { : undefined if (missing !== undefined) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${missing}`, details: { workspaceId: missing }, }) @@ -3348,7 +3348,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) if (workspace === undefined) { return sessionErr({ - code: 'workspace-not-found', + code: 'workspace/not-found', message: `no workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId }, }) @@ -3356,7 +3356,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (!workspace.sessionIds.includes(request.sessionId) || (request.beforeSessionId !== undefined && !workspace.sessionIds.includes(request.beforeSessionId))) { return sessionErr({ - code: 'workspace-move-invalid', + code: 'workspace/move-invalid', message: `session or anchor is not accounted by workspace ${request.workspaceId}`, details: { workspaceId: request.workspaceId, @@ -3378,7 +3378,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { archiveSession: (request) => { if (summaryOf(request.sessionId) === undefined) { return sessionErr({ - code: 'session-not-found', + code: 'session/not-found', message: `no session ${request.sessionId}`, details: { sessionId: request.sessionId }, }) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 36b9025d64..325834b69e 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -29,7 +29,7 @@ declare module '@deepseek-ai/cordis' { // ---- Browser-safe protocol and shared value re-exports ---- export type { MessageId, - RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, + RpcRequest, RpcResponse, RpcResult, ClientRequest, ServerResponse, RpcMessage, SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index a00277d813..a9bbc72954 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -230,7 +230,7 @@ function rpcFetchHandler( const message: ClientRequest = envelope.data if (message.method !== endpoint) { return errorResponse(message.rpcId, { - code: 'bad-request', + code: 'gateway/bad-request', message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, details: { issues: [] }, }) @@ -250,7 +250,7 @@ function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Resp const rawId = (body as { rpcId?: unknown } | null)?.rpcId const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID return errorResponse(rpcId, { - code: 'bad-request', + code: 'gateway/bad-request', message: 'invalid client-request message', details: { issues }, }) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 6cbe86d837..12c8f198be 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,7 +1,6 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Correlation id minted by a caller and echoed by the Connection response. */ export type RpcId = Branded<'rpc-id'> @@ -27,32 +26,6 @@ export type ConnectionRpcResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: ConnectionRpcFailure } -/** Typed failure details used by Client Session adapters. */ -export interface RpcErrorDetailsMap { - 'bad-request': { issues: object[] } - 'cancelled': {} - 'session-not-found': { sessionId: SessionId } - 'invalid-time-zone': { value: string } - 'agent-preset-read-only': { agentPreset: string; reason: string } - 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } - 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } - 'agent-preset-invalid': { agentPreset: string; reason: string } - 'agent-busy': { reason: string } - 'internal': {} -} - -/** Error codes used by Client Session adapters. */ -export type RpcErrorCode = keyof RpcErrorDetailsMap - -/** Typed failure used by Client Session adapters. */ -export type RpcError = { - [Code in RpcErrorCode]: { - readonly code: Code - readonly message: string - readonly details: RpcErrorDetailsMap[Code] - } -}[RpcErrorCode] - /** Historical short name for a generic Connection result. */ export type RpcResult = ConnectionRpcResult @@ -65,7 +38,7 @@ export function transportError(error: unknown): RpcResult { return { ok: false, error: { - code: 'internal', + code: 'gateway/internal', message: error instanceof Error ? error.message : String(error), details: {}, }, diff --git a/packages/client/connection/tests/api-helpers.client.spec.ts b/packages/client/connection/tests/api-helpers.client.spec.ts index 9e97cdab77..328fa8ede6 100644 --- a/packages/client/connection/tests/api-helpers.client.spec.ts +++ b/packages/client/connection/tests/api-helpers.client.spec.ts @@ -9,7 +9,7 @@ import { RpcId, resultOf, transportError } from '../src/client/api.ts' describe('transportError', () => { it('folds an Error to internal keeping the message, and stringifies non-Errors', () => { - expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'internal', message: '线断了', details: {} } }) + expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'gateway/internal', message: '线断了', details: {} } }) expect(transportError('raw string')).toMatchObject({ ok: false, error: { message: 'raw string' } }) }) }) diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index ea5c3a764b..ed7eee91b3 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -36,7 +36,7 @@ describe('createFixtureApi commands/skills', () => { it('rejects a catalog request for an unknown session', async () => { const { rpc } = createFixtureFaces() const result = await rpc.call('/api', 'commands/list', { args: { agentId: sid('fx-nope') } }) - expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + expect(result).toMatchObject({ ok: false, error: { code: 'session/not-found' } }) }) it('executes a known command line: pure admission plus a followed lifecycle pair', async () => { @@ -76,7 +76,7 @@ describe('createFixtureApi commands/skills', () => { const missing = await rpc.call('/api', 'commands/execute', { args: { agentId: sid('fx-nope'), line: '/goal ship' }, }) - expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + expect(missing).toMatchObject({ ok: false, error: { code: 'session/not-found' } }) }) it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => { @@ -165,7 +165,7 @@ describe('createFixtureApi commands/skills', () => { const missingSession = await rpc.call('/api', 'skills/list', { args: { request: { sessionId: sid('fx-nope') } }, }) - expect(missingSession).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + expect(missingSession).toMatchObject({ ok: false, error: { code: 'session/not-found' } }) }) }) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index b7e76ec767..7b5cbf0eca 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -664,7 +664,7 @@ describe('createFixtureApi', () => { const aborted = new AbortController() aborted.abort() await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal)) - .resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } }) + .resolves.toMatchObject({ result: { ok: false, error: { code: 'gateway/cancelled' } } }) }) it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => { @@ -778,7 +778,7 @@ describe('createFixtureApi', () => { ]) { expect(result).toMatchObject({ ok: false, - error: { code: 'settings-rejected', message: 'fixture: the minimal readiness settings descriptor is read-only' }, + error: { code: 'settings/rejected', message: 'fixture: the minimal readiness settings descriptor is read-only' }, }) } @@ -866,9 +866,9 @@ describe('createFixtureApi', () => { for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlFrames.push(frame) })() await new Promise(resolve => setTimeout(resolve, 10)) - // Unknown session → session-not-found with the id echoed in details. + // Unknown session → session/not-found with the id echoed in details. const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'session/not-found', details: { sessionId: 'ghost' } } }) // Real prompt: replay starts (running flips true), cancel freezes it. const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'render markdown' }] })) expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) @@ -1042,7 +1042,7 @@ describe('createFixtureApi', () => { clientId, eventId: question.eventId, outcome: { kind: 'result', value: { answers: {} } }, - })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/invocation-unavailable' } }) const remaining = await readResidentRemoteEvents(api, 1) expect(remaining.map(frame => frame.event)).toEqual(['approval/request']) @@ -1097,7 +1097,7 @@ describe('createFixtureApi', () => { clientId: await stream.clientId, eventId: approval.eventId, outcome: { kind: 'next' }, - })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/invocation-unavailable' } }) const remaining = await readResidentRemoteEvents(api, 1) expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request']) }) @@ -1172,11 +1172,11 @@ describe('createFixtureApi', () => { await new Promise(resolve => setTimeout(resolve, 10)) const wsid = 'fx-ws-fixture' as WorkspaceId const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found', details: { workspaceId: 'fx-ws-void' } } }) await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' })) const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) - expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) + expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace/name-conflict', details: { name: 'occupied' } } }) const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' })) if (!noop.result.ok) throw new Error('no-op rename failed') @@ -1210,10 +1210,10 @@ describe('createFixtureApi', () => { await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'session/not-found', details: { sessionId: 'fx-void' } } }) const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' ' })) - expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } }) + expect(blank.result).toMatchObject({ ok: false, error: { code: 'session/title-invalid', details: { sessionId: 'fx-alpha' } } }) const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' 重命名 ' })) if (!renamed.result.ok) throw new Error('rename failed') @@ -1247,11 +1247,11 @@ describe('createFixtureApi', () => { const api = createFixtureApi() const wsid = 'fx-ws-fixture' as WorkspaceId const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found' } }) const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') })) - expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } }) + expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace/move-invalid', details: { sessionId: 'fx-ghost' } } }) const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') })) - expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) + expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace/move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') })) if (!moved.result.ok) throw new Error('move failed') @@ -1276,7 +1276,7 @@ describe('createFixtureApi', () => { ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found' } }) const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) const frames = await consuming @@ -1309,7 +1309,7 @@ describe('createFixtureApi', () => { ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) - expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace/not-found', details: { workspaceId: 'fx-ws-void' } } }) const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId @@ -1384,7 +1384,7 @@ describe('createFixtureApi', () => { const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' })) expect(conflict.result).toMatchObject({ ok: false, - error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } }, + error: { code: 'session/conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } }, }) }) @@ -1416,7 +1416,7 @@ describe('createFixtureApi', () => { expect(conflict.result).toEqual({ ok: false, error: { - code: 'session-conflict', + code: 'session/conflict', message: `session ${existing.sessionId} already uses no cwd`, details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' }, }, @@ -1432,7 +1432,7 @@ describe('createFixtureApi', () => { })) expect(created.result).toMatchObject({ ok: false, - error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } }, + error: { code: 'session/workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } }, }) const listed = await api.sessions.list(req({})) const workspaces = await readWorkspaceBaseline(api.workspaceRemote) @@ -1444,7 +1444,7 @@ describe('createFixtureApi', () => { workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId, })) - expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(retried.result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } }) const afterRetry = await api.sessions.list(req({})) if (!afterRetry.result.ok) throw new Error('list failed') expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) @@ -1475,7 +1475,7 @@ describe('createFixtureApi', () => { mode: 'queue' as const, content: [{ type: 'text' as const, text: 'keep me' }], })) - expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + expect(prompt.result).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) const imagePrompt = await rejecting.sessions.prompt(req({ sessionId: real.result.value.sessionId, mode: 'queue' as const, @@ -1483,7 +1483,7 @@ describe('createFixtureApi', () => { })) expect(imagePrompt.result).toMatchObject({ ok: false, - error: { code: 'attachment-error', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' } }, + error: { code: 'session/attachment-invalid', details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' } }, }) }) @@ -1722,7 +1722,7 @@ describe('fixture Connection RPC', () => { mode: 'queue', content: [{ type: 'text', text: 'retain' }], }) - expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + expect(rejected.result).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) }) it('maps attach-failure and dropped-response query scenarios', async () => { @@ -1734,7 +1734,7 @@ describe('fixture Connection RPC', () => { }) expect(partialResult.result).toMatchObject({ ok: false, - error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } }, + error: { code: 'session/workspace-attach-failed', details: { sessionId: 'fx-query-partial' } }, }) vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 268b37e75c..6e638a19a9 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -403,7 +403,7 @@ describe('connection node half', () => { }), methodMismatch.response) expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ rpcId: 'rpc-bad', - result: { ok: false, error: { code: 'bad-request' } }, + result: { ok: false, error: { code: 'gateway/bad-request' } }, }) for (const [request, status] of [ @@ -428,7 +428,7 @@ describe('connection node half', () => { await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', body), response.response) expect(JSON.parse(String(response.state.body))).toMatchObject({ rpcId, - result: { ok: false, error: { code: 'bad-request' } }, + result: { ok: false, error: { code: 'gateway/bad-request' } }, }) } diff --git a/packages/client/connection/tests/rpc-schema.host.spec.ts b/packages/client/connection/tests/rpc-schema.host.spec.ts index 1d34e58ac3..a3338905b2 100644 --- a/packages/client/connection/tests/rpc-schema.host.spec.ts +++ b/packages/client/connection/tests/rpc-schema.host.spec.ts @@ -20,11 +20,11 @@ describe('Connection RPC schema', () => { it('folds transport exceptions into an internal failure', () => { expect(transportError(new Error('wire down'))).toEqual({ ok: false, - error: { code: 'internal', message: 'wire down', details: {} }, + error: { code: 'gateway/internal', message: 'wire down', details: {} }, }) expect(transportError('raw')).toMatchObject({ ok: false, - error: { code: 'internal', message: 'raw' }, + error: { code: 'gateway/internal', message: 'raw' }, }) }) diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index fe5a7295ff..8370696886 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -528,7 +528,7 @@ function detectBrowserLocale(locales: readonly LocaleDefinition[]): LocaleId | u } /** Required services: slot registration plus the settings transport. */ -export const inject = ['slots', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'remote', 'settingsScope'] /** * Client plugin body: provide the locale service with base dictionaries and diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index 71cf197ca3..c4a5b11284 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -38,7 +38,6 @@ async function bench() { revision += 1 return { ok: true as const, value: namespace() } }) - ctx.provide('connection', { api: {}, isLoopback: true } as never) const events = new TestRemote(ctx, { settings: { describe, mutate } }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { @@ -72,7 +71,7 @@ describe('locale apply', () => { // setLocale/Host preference instead of leaning on a dead browser pin. it('declares the slot service', () => { - expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'remote', 'settingsScope']) }) it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index b75502116e..b1e6121051 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -40,7 +40,6 @@ async function bench(preference?: string) { revision += 1 return { ok: true as const, value: namespace() } }) - ctx.provide('connection', { api: {}, isLoopback: true } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx, { settings: { describe: describeRpc, mutate } }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() diff --git a/packages/client/locale/tests/invariant.client.spec.ts b/packages/client/locale/tests/invariant.client.spec.ts index d9b1eb041e..11863dc533 100644 --- a/packages/client/locale/tests/invariant.client.spec.ts +++ b/packages/client/locale/tests/invariant.client.spec.ts @@ -21,10 +21,9 @@ describe('invariant companion', () => { it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { // The feature registers its own Language settings row, hence the slots edge. - expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'remote', 'settingsScope']) const ctx = new Context() new SlotRegistry(ctx) - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) // The settings row's transport and the forwarded-event port. ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 4926d7c001..836b4ae333 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -58,12 +58,11 @@ export const inject = [ * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const settingsWire = { settings: ctx.remote.settings } - const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe()) + const controller = new AgentPresetSettingsController(ctx, 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(ctx.remote, () => { + const section = new AgentPresetSectionController(ctx, () => { void controller.load() for (const read of rosterReaders) read() }) @@ -105,7 +104,7 @@ export function apply(ctx: ClientContext): void { // The new-session chip and the header label: one controller, because the // staged choice belongs to the flow rather than to any one session. ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => { - const seat = new AgentPresetSeatController(scope.remote, () => { + const seat = new AgentPresetSeatController(scope, () => { const state = scope.sessions.list.getSnapshot() return state.current === undefined ? undefined : state.byId[state.current] }) diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 0f9f51d4eb..4c729197b9 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -10,11 +10,13 @@ * deployment default again, matching the workspace picker beside it. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { messageOf, presetOptions, readRoster } from './settings-store.ts' +import { presetOptions, readRoster } from './settings-store.ts' import type { AgentPresetOption } from './settings-store.ts' /** Hero-chip snapshot. */ @@ -53,7 +55,7 @@ export class AgentPresetSeatController { private staged: string | undefined constructor( - private readonly remote: Pick, + private readonly ctx: ClientContext, /** The session the hero is about to hand over to, when there is one. */ private readonly currentSession: () => Pick< SessionSummary, @@ -70,7 +72,7 @@ export class AgentPresetSeatController { * @returns once the snapshot reflects the host. */ async load(): Promise { - const roster = await readRoster(this.remote) + const roster = await readRoster(this.ctx) if (!roster.ok) { this.set({ error: roster.error }) return @@ -155,35 +157,26 @@ export class AgentPresetSeatController { return } this.set({ busy: true, error: null }) - try { - const result = await this.remote.agentPresets.select(session.id, staged) - this.staged = undefined - if (!result.ok) { - const { error } = result - this.set({ - busy: false, - // A refusal carries its cause twice: `message` wraps it in the - // roster's own frame, which names the preset the surface reporting - // this already names, and a `reason` detail holds the same cause - // without it. Read by the detail rather than by the code, because - // every refusal that has a cause to give names it the same way. - error: 'reason' in error.details && typeof error.details.reason === 'string' - ? error.details.reason - : error.message, - current: presetOf(session) ?? '', - }) - return - } - // Consumed: the next new session opens on the deployment default again. - this.set({ busy: false, current: result.value }) - } catch (error) { - this.staged = undefined + const result = await this.ctx.remote.agentPresets.select(session.id, staged) + this.staged = undefined + if (!result.ok) { + const { error } = result this.set({ busy: false, - error: messageOf(error), + // A refusal carries its cause twice: `message` wraps it in the + // roster's own frame, which names the preset the surface reporting + // this already names, and a `reason` detail holds the same cause + // without it. Read by the detail rather than by the code, because + // every refusal that has a cause to give names it the same way. + error: 'reason' in error.details && typeof error.details.reason === 'string' + ? error.details.reason + : error.message, current: presetOf(session) ?? '', }) + return } + // Consumed: the next new session opens on the deployment default again. + this.set({ busy: false, current: result.value }) } } diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 43db65686d..d63ee2176c 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -14,9 +14,11 @@ * more than the row it targeted. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} 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' +import { beginRosterRead, writeDefaultPreset } from './settings-store.ts' /** Ids a preset directory may be named, mirroring the host's own rule. */ const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ @@ -133,7 +135,7 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly remote: Pick, + private readonly ctx: ClientContext, /** * Called after this page changes the roster DIRECTORY, so the other * surfaces reading the same roster re-read it. A settings field moving is @@ -167,13 +169,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.remote.settings.canOpenAgentPresetDirectory() - const roster = await beginRosterRead(this.remote, this.store) + const opener = this.ctx.remote.settings.canOpenAgentPresetDirectory() + const roster = await beginRosterRead(this.ctx, this.store) // A refused describe leaves the reveal-the-path path, which needs no opener. - const described = await opener.catch(() => undefined) + const described = await opener if (roster === undefined) return const { presets, authorable } = roster - const hasDocument = described?.ok === true && described.value + const hasDocument = described.ok && 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 }) @@ -201,17 +203,13 @@ export class AgentPresetSectionController { */ async view(id: string): Promise { this.set({ error: null }) - try { - const result = await this.remote.agentPresets.read(id) - if (!result.ok) { - this.set({ error: result.error.message }) - return - } - const { name, content } = result.value - this.set({ view: { id, title: name ?? id, content } }) - } catch (error) { - this.set({ error: messageOf(error) }) + const result = await this.ctx.remote.agentPresets.read(id) + if (!result.ok) { + this.set({ error: result.error.message }) + return } + const { name, content } = result.value + this.set({ view: { id, title: name ?? id, content } }) } /** Close the read-only viewer. */ @@ -263,27 +261,23 @@ export class AgentPresetSectionController { if (draft === null || draft.saving) return if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return this.patchCopy({ saving: true, error: null }) - try { - const name = draft.name.trim() - // Every declared parameter is passed even when optional: the Remote face - // checks arity against the declaration and rejects a short call. An - // empty display name goes as `undefined` — absent rather than empty, so - // the host falls back to the id instead of labelling the row with ''. - const result = await this.remote.agentPresets.copy( - draft.from, draft.id, name === '' ? undefined : name) - if (!result.ok) { - this.patchCopy({ saving: false, error: result.error.message }) - return - } - this.set({ copy: null }) - await this.load() - this.rosterChanged() - // A preset is its files from here on (the dialog collected nothing - // else), so landing in them is the completion, not a follow-up. - await this.openLocation(draft.id) - } catch (error) { - this.patchCopy({ saving: false, error: messageOf(error) }) + const name = draft.name.trim() + // Every declared parameter is passed even when optional: the Remote face + // checks arity against the declaration and rejects a short call. An + // empty display name goes as `undefined` — absent rather than empty, so + // the host falls back to the id instead of labelling the row with ''. + const result = await this.ctx.remote.agentPresets.copy( + draft.from, draft.id, name === '' ? undefined : name) + if (!result.ok) { + this.patchCopy({ saving: false, error: result.error.message }) + return } + this.set({ copy: null }) + await this.load() + this.rosterChanged() + // A preset is its files from here on (the dialog collected nothing + // else), so landing in them is the completion, not a follow-up. + await this.openLocation(draft.id) } /** @@ -293,18 +287,14 @@ export class AgentPresetSectionController { * @returns once the host answered and the page reflects it. */ async openLocation(id: string): Promise { - try { - const result = await this.remote.settings.openAgentPresetDirectory(id) - if (!result.ok) { - this.set({ error: result.error.message }) - return - } - if (result.value.opened) return - const { path } = result.value - this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } }) - } catch (error) { - this.set({ error: messageOf(error) }) + const result = await this.ctx.remote.settings.openAgentPresetDirectory(id) + if (!result.ok) { + this.set({ error: result.error.message }) + return } + if (result.value.opened) return + const { path } = result.value + this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } }) } /** @@ -327,18 +317,14 @@ export class AgentPresetSectionController { const { pendingDelete, deleting } = this.store.getSnapshot() if (pendingDelete === null || deleting) return this.set({ deleting: true, error: null }) - try { - const result = await this.remote.agentPresets.deletePreset(pendingDelete) - if (!result.ok) { - this.set({ deleting: false, pendingDelete: null, error: result.error.message }) - return - } - this.set({ deleting: false, pendingDelete: null }) - await this.load() - this.rosterChanged() - } catch (error) { - this.set({ deleting: false, pendingDelete: null, error: messageOf(error) }) + const result = await this.ctx.remote.agentPresets.deletePreset(pendingDelete) + if (!result.ok) { + this.set({ deleting: false, pendingDelete: null, error: result.error.message }) + return } + this.set({ deleting: false, pendingDelete: null }) + await this.load() + this.rosterChanged() } /** @@ -348,7 +334,7 @@ export class AgentPresetSectionController { * @returns once the write settled and the roster was re-read. */ async makeDefault(id: string): Promise { - const failure = await writeDefaultPreset(this.remote, id) + const failure = await writeDefaultPreset(this.ctx, id) if (failure !== undefined) { this.set({ error: failure }) return diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index 398b781e67..1792f204dd 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -7,51 +7,35 @@ * namespace's `default` field, which is what the host resolves at creation. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types' -import type { SettingsDescribeFace, SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** The agent-preset settings namespace on the host wire. */ export const AGENT_PRESET_SETTINGS_NS = 'agent-presets' -/** - * Human text for a rejected wire call. A transport failure rejects with an - * Error; a host or a runtime can reject with anything, and the surface still - * has to say something. - * @param error - the rejection value. - * @returns the message to show. - */ -export function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - /** * Persist one preset as the default for sessions created later. * * The default is a settings field rather than a preset property, so both the * General row and the management section write it here — one home for which * namespace and field the host resolves at session creation. - * @param api - the settings wire face. + * @param ctx - the browser plugin context carrying the Remote namespaces. * @param id - the preset to make default. * @returns the failure message, or undefined once the write landed. */ export async function writeDefaultPreset( - api: SettingsWireFace, + ctx: ClientContext, id: string, ): Promise { - let response - try { - response = await api.settings.update( - AGENT_PRESET_SETTINGS_NS, - { default: id }, - undefined, - ) - } catch (error) { - // The transport rejected rather than answering; the caller must be able to - // say so instead of the row silently snapping back. - return messageOf(error) - } + const response = await ctx.remote.settings.update( + AGENT_PRESET_SETTINGS_NS, + { default: id }, + undefined, + ) return response.ok ? undefined : response.error.message } @@ -76,27 +60,18 @@ export type RosterRead = { ok: true; value: AgentPresetRoster } | { ok: false; e const EMPTY_ROSTER: AgentPresetRoster = { presets: [], authorable: false } /** - * Read the roster, folding both refusal shapes into one message. - * - * The wire refuses in two ways — the transport rejects, or it answers an - * `ok: false` envelope — and every surface treats them identically. Folding - * them here keeps each store's `load` about what it does with a roster rather - * than about how the call can fail. - * @param remote - the agent-preset Remote namespace. + * Read the roster, turning a refusal into the message every surface shows. + * @param ctx - the browser plugin context carrying the Remote namespaces. * @returns the roster, or the message to show in its place. */ -export async function readRoster(remote: Pick): Promise { - try { - const result = await remote.agentPresets.list() - if (result.ok) return { ok: true, value: result.value } - // Agent presets are optional: without that service every session uses the - // Host composition, so callers receive the same empty roster as a mounted - // service with no configured roots. - if (result.error.code === 'invocation-unavailable') return { ok: true, value: EMPTY_ROSTER } - return { ok: false, error: result.error.message } - } catch (error) { - return { ok: false, error: messageOf(error) } - } +export async function readRoster(ctx: ClientContext): Promise { + const result = await ctx.remote.agentPresets.list() + if (result.ok) return { ok: true, value: result.value } + // Agent presets are optional: without that service every session uses the + // Host composition, so callers receive the same empty roster as a mounted + // service with no configured roots. + if (result.error.code === 'gateway/invocation-unavailable') return { ok: true, value: EMPTY_ROSTER } + return { ok: false, error: result.error.message } } /** @@ -106,18 +81,18 @@ export async function readRoster(remote: Pick): Pr * A surface that gets `undefined` returns without touching its snapshot * further — either another read owns it, or this one already wrote the * failure. What differs between surfaces starts after this. - * @param remote - the agent-preset Remote namespace. + * @param ctx - the browser plugin context carrying the Remote namespaces. * @param store - the surface's own snapshot store. * @returns the roster, or undefined when the caller should return. */ export async function beginRosterRead( - remote: Pick, + ctx: ClientContext, store: SnapshotStore, ): Promise { const before = store.getSnapshot() if (before.status === 'loading') return undefined store.set({ ...before, status: 'loading', error: null }) - const roster = await readRoster(remote) + const roster = await readRoster(ctx) if (roster.ok) return roster.value store.set({ ...store.getSnapshot(), status: 'error', error: roster.error }) return undefined @@ -180,13 +155,11 @@ export class AgentPresetSettingsController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) /** - * @param api - the settings wire face (the default write). - * @param remote - the agent-preset Remote namespace (the roster read). + * @param ctx - the browser plugin context (the roster read and the default write). * @param describeFace - the shared mirror's describe face (writability source). */ constructor( - private readonly api: SettingsWireFace, - private readonly remote: Pick, + private readonly ctx: ClientContext, private readonly describeFace: SettingsDescribeFace, ) {} @@ -201,7 +174,7 @@ export class AgentPresetSettingsController { * @returns once the snapshot reflects the host. */ async load(): Promise { - const roster = await beginRosterRead(this.remote, this.store) + const roster = await beginRosterRead(this.ctx, this.store) if (roster === undefined) return const { presets } = roster const [first] = presets @@ -236,7 +209,7 @@ export class AgentPresetSettingsController { const before = this.store.getSnapshot() if (before.status === 'saving' || id === before.currentValue) return this.set({ status: 'saving', error: null, currentValue: id }) - const failure = await writeDefaultPreset(this.api, id) + const failure = await writeDefaultPreset(this.ctx, id) if (failure !== undefined) { this.set({ status: 'ready', currentValue: before.currentValue, error: failure }) return diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index c2488de4af..11bd8dcf6f 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { SessionId } from '@deepseek-ai/dsh-session' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' @@ -115,7 +115,6 @@ async function bench() { } ctx.provide('remote.agentPresets', agentPresets as never) Object.assign(remote, { agentPresets }) - 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 } } @@ -581,8 +580,10 @@ describe('AgentPresetSeatController reconciliation', () => { it('uses the deployment default without a Session and clears it for an uncomposed Session', async () => { const state: { current?: { id: SessionId; blank: boolean } } = {} const controller = new AgentPresetSeatController({ - agentPresets: { - list: () => Promise.resolve(ROSTER_ONE), + remote: { + agentPresets: { + list: () => Promise.resolve(ROSTER_ONE), + }, }, } as never, () => state.current) @@ -595,43 +596,35 @@ describe('AgentPresetSeatController reconciliation', () => { expect(controller.store.getSnapshot().current).toBe('') }) - it.each([ - { - name: 'RPC rejection', - select: () => Promise.resolve({ - ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} }, - }), - message: 'selection rejected', - }, - { - name: 'transport failure', - select: () => Promise.reject(new Error('transport failed')), - message: 'transport failed', - }, - ])('restores an empty current value after $name for an uncomposed Session', async ({ select, message }) => { + it('restores an empty current value after a refused switch for an uncomposed Session', async () => { + const select = () => Promise.resolve({ + ok: false as const, error: new RemoteError('gateway/internal', 'selection rejected', {}), + }) const controller = new AgentPresetSeatController({ - agentPresets: { select }, + remote: { agentPresets: { select } }, } as never, () => ({ id: SessionId('uncomposed'), blank: true })) await controller.select('minimal') expect(controller.store.getSnapshot()).toMatchObject({ - busy: false, current: '', error: message, + busy: false, current: '', error: 'selection rejected', }) }) it('keeps the bare cause of a mount failure, not the frame that names the preset again', async () => { const reason = 'failed to import loader entry ctx (@deepseek-ai/dsh-gone): Cannot find package' const controller = new AgentPresetSeatController({ - agentPresets: { - select: () => Promise.resolve({ - ok: false as const, - error: { - code: 'agent-preset-invalid', - message: `agent-presets: preset "broken" failed to mount: ${reason}`, - details: { agentPreset: 'broken', reason }, - }, - }), + remote: { + agentPresets: { + select: () => Promise.resolve({ + ok: false as const, + error: new RemoteError( + 'agent-preset/invalid', + `agent-presets: preset "broken" failed to mount: ${reason}`, + { agentPreset: 'broken', reason }, + ), + }), + }, }, } as never, () => ({ id: SessionId('uncomposed'), blank: true })) diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index 0b732c4cf5..0f3371df90 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -7,7 +7,8 @@ */ import { describe, expect, it } from 'vitest' -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' @@ -29,27 +30,19 @@ interface FakeOptions { failRemove?: string /** Reject `settings.update` with this message. */ failSettings?: string - /** Throw from `list` rather than answering, as a dead transport does. */ - throwList?: boolean - /** Throw from `read`, as a dead transport does. */ - throwRead?: boolean - /** Throw from `copy`, as a dead transport does. */ - throwCopy?: boolean - /** Throw from `openDocument`, as a dead transport does. */ - throwOpen?: boolean /** Whether the deployment configures a writable root. */ authorable?: boolean /** Whether the host can open a preset directory on a desktop. */ hasDocument?: boolean - /** Reject the opener capability read, as a dead transport does. */ - throwCapability?: boolean + /** Refuse the opener capability read. */ + failCapability?: string /** Hold `remove` until this resolves, to observe the in-flight state. */ holdRemove?: Promise } 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: {} } }) + Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', message, {}) }) /** * The Remote namespace over an in-memory preset store: copies land, so the @@ -57,95 +50,93 @@ const remoteFail = (message: string) => * @param presets - the starting compositions by id. * @param defaultId - the preset a session with no choice gets. * @param options - failure injection and call recording. - * @returns the fake Remote namespace. + * @returns the fake plugin context carrying the Remote namespaces. */ -function fakeRemote( +function fakeCtx( presets: Map, defaultId: { id: string }, options: FakeOptions = {}, -): Pick { +): ClientContext { const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } return { - agentPresets: { - list: () => { - record('list', {}) - if (options.throwList === true) return Promise.reject(new Error('socket closed')) - if (options.failList !== undefined) return remoteFail(options.failList) - return remoteOk({ - presets: [...presets].map(([id, preset]) => ({ - id, trust: preset.trust, isDefault: id === defaultId.id, + remote: { + agentPresets: { + list: () => { + record('list', {}) + if (options.failList !== undefined) return remoteFail(options.failList) + return remoteOk({ + presets: [...presets].map(([id, preset]) => ({ + id, trust: preset.trust, isDefault: id === defaultId.id, + ...preset.name === undefined ? {} : { name: preset.name }, + })), + authorable: options.authorable ?? true, + }) + }, + read: (agentPreset: string) => { + record('read', { agentPreset }) + if (options.failRead !== undefined) return remoteFail(options.failRead) + const preset = presets.get(agentPreset) + /* v8 ignore next -- every test reads an id the fake store holds */ + if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`) + return remoteOk({ + agentPreset, + trust: preset.trust, + content: preset.content, ...preset.name === undefined ? {} : { name: preset.name }, - })), - authorable: options.authorable ?? true, - }) + }) + }, + // Arity is checked against the declaration, not against which arguments + // carry a value, so a short call rejects instead of answering. Reject + // one here too: the real face would, and a lenient double hid it once. + copy: (...args: [from: string, id: string, name?: string]) => { + if (args.length !== 3) { + return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`)) + } + const [from, id, name] = args + record('copy', { from, id, ...name === undefined ? {} : { name } }) + if (options.failCopy !== undefined) return remoteFail(options.failCopy) + const source = presets.get(from) + /* v8 ignore next -- every test copies a source the fake store holds */ + if (source === undefined) return remoteFail(`unknown preset ${from}`) + presets.set(id, { + trust: 'user', + content: source.content, + ...name === undefined ? {} : { name }, + }) + return remoteOk(undefined) + }, + deletePreset: async (id: string) => { + record('deletePreset', { id }) + await options.holdRemove + if (options.failRemove !== undefined) return await remoteFail(options.failRemove) + presets.delete(id) + return await remoteOk(undefined) + }, }, - read: (agentPreset: string) => { - record('read', { agentPreset }) - if (options.throwRead === true) return Promise.reject(new Error('socket closed')) - if (options.failRead !== undefined) return remoteFail(options.failRead) - const preset = presets.get(agentPreset) - /* v8 ignore next -- every test reads an id the fake store holds */ - if (preset === undefined) return remoteFail(`unknown preset ${agentPreset}`) - return remoteOk({ - agentPreset, - trust: preset.trust, - content: preset.content, - ...preset.name === undefined ? {} : { name: preset.name }, - }) - }, - // Arity is checked against the declaration, not against which arguments - // carry a value, so a short call rejects instead of answering. Reject - // one here too: the real face would, and a lenient double hid it once. - copy: (...args: [from: string, id: string, name?: string]) => { - if (args.length !== 3) { - return Promise.reject(new Error(`client api: agentPresets/copy expected 3 argument(s), got ${String(args.length)}`)) - } - const [from, id, name] = args - record('copy', { from, id, ...name === undefined ? {} : { name } }) - if (options.throwCopy === true) return Promise.reject(new Error('socket closed')) - if (options.failCopy !== undefined) return remoteFail(options.failCopy) - const source = presets.get(from) - /* v8 ignore next -- every test copies a source the fake store holds */ - if (source === undefined) return remoteFail(`unknown preset ${from}`) - presets.set(id, { - trust: 'user', - content: source.content, - ...name === undefined ? {} : { name }, - }) - return remoteOk(undefined) - }, - deletePreset: async (id: string) => { - record('deletePreset', { id }) - await options.holdRemove - if (options.failRemove !== undefined) return await remoteFail(options.failRemove) - presets.delete(id) - return await remoteOk(undefined) + settings: { + canOpenAgentPresetDirectory: () => { + record('canOpenAgentPresetDirectory', {}) + return options.failCapability === undefined + ? remoteOk(options.hasDocument ?? true) + : remoteFail(options.failCapability) + }, + update: (ns: string, patch: { default?: string }) => { + record('settings.update', { ns, patch }) + if (options.failSettings !== undefined) return remoteFail(options.failSettings) + /* v8 ignore next -- the controller only ever sets `default` */ + defaultId.id = patch.default ?? defaultId.id + return remoteOk({}) + }, + openAgentPresetDirectory: (agentPreset: string) => { + record('openAgentPresetDirectory', { agentPreset }) + if (options.failOpen !== undefined) return remoteFail(options.failOpen) + return (options.hasDocument ?? true) + ? remoteOk({ opened: true }) + : remoteOk({ opened: false, path: `/presets/${agentPreset}` }) + }, }, }, - 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) - /* v8 ignore next -- the controller only ever sets `default` */ - defaultId.id = patch.default ?? defaultId.id - return remoteOk({}) - }, - openAgentPresetDirectory: (agentPreset: string) => { - record('openAgentPresetDirectory', { agentPreset }) - if (options.throwOpen === true) return Promise.reject(new Error('socket closed')) - if (options.failOpen !== undefined) return remoteFail(options.failOpen) - return (options.hasDocument ?? true) - ? remoteOk({ opened: true }) - : remoteOk({ opened: false, path: `/presets/${agentPreset}` }) - }, - }, - } as unknown as Pick + } as unknown as ClientContext } function seed(): Map { @@ -162,7 +153,7 @@ function harness(options: FakeOptions = {}) { let rosterChanges = 0 const wired = { ...options, calls: options.calls ?? calls } const controller = new AgentPresetSectionController( - fakeRemote(presets, defaultId, wired), + fakeCtx(presets, defaultId, wired), () => { rosterChanges += 1 }, ) return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges } @@ -175,8 +166,8 @@ 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({ throwCapability: true }) + it('still lists the roster when the opener capability is refused', async () => { + const { controller } = harness({ failCapability: 'no opener here' }) await controller.load() @@ -228,14 +219,6 @@ describe('loading the roster', () => { expect(state.error).toBe('not for you') }) - it('folds a dead transport into the same error surface', async () => { - const { controller } = harness({ throwList: true }) - - await controller.load() - - expect(controller.store.getSnapshot().status).toBe('error') - expect(controller.store.getSnapshot().error).toContain('socket closed') - }) }) describe('the read-only viewer', () => { @@ -280,14 +263,6 @@ describe('the read-only viewer', () => { expect(controller.store.getSnapshot().error).toBe('no peeking') }) - it('folds a dead transport into the same error surface', async () => { - const { controller } = harness({ throwRead: true }) - await controller.load() - - await controller.view('standard') - - expect(controller.store.getSnapshot().error).toContain('socket closed') - }) }) describe('the copy dialog', () => { @@ -423,17 +398,6 @@ describe('submitting a copy', () => { expect(rosterChanges()).toBe(0) }) - it('folds a dead transport into the dialog error', async () => { - const { controller } = harness({ throwCopy: true }) - await controller.load() - controller.beginCopy('standard') - controller.setCopyId('my-copy') - - await controller.confirmCopy() - - expect(copyOf(controller).error).toContain('socket closed') - }) - it('refuses to submit while blocked or already saving', async () => { const { controller, calls } = harness() await controller.load() @@ -486,14 +450,6 @@ describe('the location action', () => { expect(controller.store.getSnapshot().error).toBe('not yours') }) - it('folds a dead transport into the same error surface', async () => { - const { controller } = harness({ throwOpen: true }) - await controller.load() - - await controller.openLocation('mine') - - expect(controller.store.getSnapshot().error).toContain('socket closed') - }) }) describe('deleting', () => { @@ -552,25 +508,6 @@ describe('deleting', () => { expect(state.deleting).toBe(false) }) - it('folds a dead transport into the same error surface', async () => { - const { controller, presets } = harness() - await controller.load() - presets.clear() - const broken = new AgentPresetSectionController( - { - agentPresets: { - list: () => Promise.reject(new Error('gone')), - deletePreset: () => Promise.reject(new Error('socket closed')), - }, - settings: {}, - } as unknown as Pick, - ) - broken.confirmDelete('mine') - - await broken.remove() - - expect(broken.store.getSnapshot().error).toContain('socket closed') - }) }) describe('a controller with no roster listener', () => { @@ -580,7 +517,7 @@ describe('a controller with no roster listener', () => { const presets = seed() const defaultId = { id: 'standard' } const alone = new AgentPresetSectionController( - fakeRemote(presets, defaultId)) + fakeCtx(presets, defaultId)) await alone.load() alone.confirmDelete('mine') diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 4d8544dfcf..690dee35e5 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -6,24 +6,19 @@ */ import { describe, expect, it } from 'vitest' -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { RemoteErrorCode } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, + AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, } from '../src/client/settings-store.ts' -/** The two faces the row reads: the roster Remote and the settings wire. */ -interface FakeWire { - api: SettingsWireFace - remote: Pick -} - -/** Controller over a real mirror derived from the same fake wire. */ -function derivedController(wire: FakeWire) { - return new AgentPresetSettingsController(wire.api, wire.remote, new SettingsDescribeMirror(wire.api)) +/** Controller over a real mirror derived from the same scripted context. */ +function derivedController(ctx: ClientContext) { + return new AgentPresetSettingsController(ctx, new SettingsDescribeMirror(ctx)) } import { AgentPresetSeatController } from '../src/client/seat-store.ts' @@ -34,60 +29,58 @@ interface Recorded { ns: string; ops: unknown } /** A roster Remote answering a fixed set of rows, or refusing. */ function fakeRoster( presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], - options: { failList?: string; failListCode?: string; throwOnList?: boolean } = {}, -): Pick { + options: { failList?: string; failListCode?: RemoteErrorCode; settings?: object } = {}, +): ClientContext { return { - agentPresets: { - list: () => { - if (options.throwOnList === true) return Promise.reject(new Error('socket closed')) - return Promise.resolve(options.failList === undefined - ? { ok: true as const, value: { presets, authorable: true } } - : { - ok: false as const, - error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} }, - }) + remote: { + ...options.settings === undefined ? {} : { settings: options.settings }, + agentPresets: { + list: () => { + return Promise.resolve(options.failList === undefined + ? { ok: true as const, value: { presets, authorable: true } } + : { + ok: false as const, + error: new RemoteError(options.failListCode ?? 'gateway/internal', options.failList, {}), + }) + }, }, }, - } as unknown as Pick + } as unknown as ClientContext } -/** A wire whose roster and write outcome the test controls. */ +/** A context whose roster and settings write outcome the test controls. */ function fakeApi( presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[], options: { writes?: Recorded[] failWrite?: string failList?: string - failWriteWith?: Error readOnly?: boolean } = {}, -): FakeWire { - const api = { - settings: { - // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false - // and the row disables its control instead of offering a refused write. - describe: () => Promise.resolve({ - ok: true as const, - value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, - }), - update: (ns: string, patch: { default?: unknown }) => { - options.writes?.push({ ns, ops: patch }) - if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith) - if (options.failWrite !== undefined) { - return Promise.resolve({ ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } }) - } - // A committed write moves the roster's default. - for (const preset of presets) { - preset.isDefault = preset.id === patch.default - } - return Promise.resolve({ ok: true as const, value: {} }) - }, +): ClientContext { + const settings = { + // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false + // and the row disables its control instead of offering a refused write. + describe: () => Promise.resolve({ + ok: true as const, + value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, + }), + update: (ns: string, patch: { default?: unknown }) => { + options.writes?.push({ ns, ops: patch }) + if (options.failWrite !== undefined) { + return Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', options.failWrite, {}) }) + } + // A committed write moves the roster's default. + for (const preset of presets) { + preset.isDefault = preset.id === patch.default + } + return Promise.resolve({ ok: true as const, value: {} }) }, - } as unknown as SettingsWireFace - return { - api, - remote: fakeRoster(presets, options.failList === undefined ? {} : { failList: options.failList }), } + return fakeRoster(presets, { + settings, + ...options.failList === undefined ? {} : { failList: options.failList }, + }) } describe('the agent-preset settings controller', () => { @@ -162,13 +155,10 @@ describe('the agent-preset settings controller', () => { }) it('treats an unavailable optional namespace as an empty roster', async () => { - const controller = derivedController({ - api: {} as SettingsWireFace, - remote: fakeRoster([], { - failList: 'no active Remote method exports this endpoint', - failListCode: 'invocation-unavailable', - }), - }) + const controller = derivedController(fakeRoster([], { + failList: 'no active Remote method exports this endpoint', + failListCode: 'gateway/invocation-unavailable', + })) await controller.load() @@ -252,37 +242,6 @@ describe('the agent-preset settings controller', () => { expect(controller.store.getSnapshot().status).toBe('ready') }) - it('reads an Error\'s message and stringifies anything else', () => { - // A transport rejects with an Error, but a host or a runtime can reject - // with anything and the surface still has to say something. - expect(messageOf(new Error('boom'))).toBe('boom') - expect(messageOf({ code: 7 })).toBe('[object Object]') - }) - - it('reports a transport that rejects rather than answering', async () => { - const controller = derivedController({ - api: {} as SettingsWireFace, - remote: fakeRoster([], { throwOnList: true }), - }) - - await controller.load() - - expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' }) - }) - - it('reports a transport that rejects mid-write and keeps the old default showing', async () => { - const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, - { id: 'mine', trust: 'user', isDefault: false }, - ], { failWriteWith: new Error('socket closed') })) - await controller.load() - - await controller.select('mine') - - // The value snaps back because the host never took it; a picker still - // showing "mine" would be claiming a default that does not exist. - expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' }) - }) }) describe('the new-session chip controller', () => { @@ -294,39 +253,36 @@ describe('the new-session chip controller', () => { writes?: Recorded[] failSelect?: string failList?: string - failListCode?: string - throwOn?: 'list' | 'select' + failListCode?: RemoteErrorCode } = {}, ): AgentPresetSeatController { - const remote = { - agentPresets: { - list: () => { - if (options.throwOn === 'list') return Promise.reject(new Error('socket closed')) - return Promise.resolve(options.failList === undefined - ? { ok: true as const, value: { presets, authorable: true } } - : { - ok: false as const, - error: { code: options.failListCode ?? 'internal', message: options.failList, details: {} }, - }) - }, - select: (agentId: SessionId, agentPreset: string) => { - if (options.throwOn === 'select') return Promise.reject(new Error('socket closed')) - options.writes?.push({ ns: 'select', ops: agentPreset }) - return Promise.resolve(options.failSelect === undefined - ? { ok: true as const, value: agentPreset } - : { - ok: false as const, - error: { - code: 'agent-preset-locked', - message: options.failSelect, - details: { sessionId: agentId, agentPreset }, - }, - }) + const ctx = { + remote: { + agentPresets: { + list: () => { + return Promise.resolve(options.failList === undefined + ? { ok: true as const, value: { presets, authorable: true } } + : { + ok: false as const, + error: new RemoteError(options.failListCode ?? 'gateway/internal', options.failList, {}), + }) + }, + select: (agentId: SessionId, agentPreset: string) => { + options.writes?.push({ ns: 'select', ops: agentPreset }) + return Promise.resolve(options.failSelect === undefined + ? { ok: true as const, value: agentPreset } + : { + ok: false as const, + error: new RemoteError('agent-preset/locked', options.failSelect, { + sessionId: agentId, agentPreset, + }), + }) + }, }, }, - } as unknown as Pick + } as unknown as ClientContext return new AgentPresetSeatController( - remote, + ctx, typeof current === 'function' ? current : () => current, ) } @@ -385,7 +341,7 @@ describe('the new-session chip controller', () => { it('opens on nothing when the optional namespace is unavailable', async () => { const controller = chip([], undefined, { failList: 'no active Remote method exports this endpoint', - failListCode: 'invocation-unavailable', + failListCode: 'gateway/invocation-unavailable', }) await controller.load() @@ -505,24 +461,6 @@ describe('the new-session chip controller', () => { expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' }) }) - it('falls back to the default when the switch never reaches the host', async () => { - const controller = chip( - ROSTER, - { - id: 's1' as SessionId, - blank: true, - projectionValues: { agentPreset: 'standard' }, - }, - { throwOn: 'select' }, - ) - await controller.load() - - await controller.select('minimal') - - expect(controller.store.getSnapshot()) - .toMatchObject({ current: 'standard', busy: false, error: 'socket closed' }) - }) - it('ignores a pick while a switch is in flight', async () => { const writes: Recorded[] = [] const controller = chip(ROSTER, { @@ -559,21 +497,12 @@ describe('the new-session chip controller', () => { expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] }) }) - it('reports a transport that rejects the roster read', async () => { - const controller = chip(ROSTER, undefined, { throwOn: 'list' }) - - await controller.load() - - expect(controller.store.getSnapshot().error).toBe('socket closed') - }) - it('degrades to a read-only row while the mirror holds no answer', async () => { - const controller = derivedController({ - // The roster answered; the mirror's read is what failed, so the row - // shows the current default without offering a write it never confirmed. - api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as SettingsWireFace, - remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]), - }) + // The roster answered; the mirror's read is what failed, so the row + // shows the current default without offering a write it never confirmed. + const controller = derivedController(fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }], { + settings: { describe: () => Promise.reject(new Error('socket closed')) }, + })) await controller.load() diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 9ceef64dd7..052c2b3bf8 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -5,7 +5,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { - SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, + RemoteError, SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' @@ -127,7 +127,7 @@ describe('Chat inject API', () => { b.openWorkspacePath.mockResolvedValueOnce({ ok: false, - error: { code: 'internal', message: 'xdg-open is not available', details: {} }, + error: new RemoteError('gateway/internal', 'xdg-open is not available', {}), }) await expect(injected.openFile('src/b.ts')).rejects.toThrow('path open failed: xdg-open is not available') await b.runtime.dispose() diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 1c795904b9..607bf69cb5 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -2,8 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ChatConversationViewNode, ConversationNode, diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 084d372ef0..01e17ed71e 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -5,8 +5,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { AssistantMessageNode, ChatSnapshot, LegacyConversationSlice, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { StatsLine, deriveStats, formatDuration, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index bc3022cc3d..2acc40853e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -16,10 +16,9 @@ import type { import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' @@ -2275,7 +2274,7 @@ describe('ChatView', () => { it('shows open error and loading states', () => { const h = makeHarness({}, { openState: 'error', - openError: { code: 'internal', message: 'boom' } as never, + openError: { code: 'gateway/internal', message: 'boom' } as never, }) const view = render() expect(view.getByText(/历史加载失败:boom/)).toBeTruthy() diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index cc1cf29745..57afd2abd8 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState, SessionSnapshot, @@ -15,7 +15,6 @@ import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversa import type { DetailsSlotProps, DetailsToolOwnerProps, RunningToolCall, SelectionTarget, } from '@deepseek-ai/dsh-client-ui-chat/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index bef9f0c3bc..4a606fe607 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -395,7 +395,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * the outcome renders as a persistent flow node — the composer never * echoes it. A handler error result reports an error outcome so the * composer keeps the submission (draft and images) for correction. - * Transport failures throw. + * A refused call throws. */ private async execute( session: ClientSessionContext, @@ -441,9 +441,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * Fire-and-forget execute for the internal ('handled') paths. Outcomes are * NOT surfaced here: the host executor durably logs the command lifecycle * (`command/run`/`command/done`), and the mux-broadcast events render as a - * persistent flow node on every tab. Only a transport/admission failure — - * which never entered a handler and therefore never logged — falls back to - * the composer notice as immediate feedback. + * persistent flow node on every tab. Only an admission failure — which never + * entered a handler and therefore never logged — falls back to the composer + * notice as immediate feedback. */ private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void { void this.execute(session, line).then( @@ -468,7 +468,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { }) } - /** Route an admission/transport failure to the session's composer notice channel (scope gone = attempt died with it). */ + /** Route an admission failure to the session's composer notice channel (scope gone = attempt died with it). */ private noticeFor(id: SessionId, level: 'info' | 'error', text: string): void { const actx = this.scopeFor(id) if (actx === undefined) return diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index def2baa09c..ae8ef7a884 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' @@ -44,8 +44,8 @@ interface BenchOptions { /** * Fold one programmed answer into the generated Remote face's outcome: a - * resolved value is the ok branch, a rejection is the transport failure the - * carrier reports in the error branch instead of throwing at the caller. + * resolved value is the ok branch, a rejection is the carrier failure the + * Remote face reports in the error branch instead of throwing at the caller. * @param produce - the scripted answer for one Remote method. * @returns the carried result the service reads. */ @@ -55,11 +55,7 @@ async function carried(produce: () => Promise) { } catch (error) { return { ok: false as const, - error: { - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, - }, + error: new RemoteError('gateway/internal', error instanceof Error ? error.message : String(error), {}), } } } @@ -660,7 +656,7 @@ describe('detached admission notices', () => { expect(notices).toEqual([{ scope: sid('s1'), level: 'error', - text: 'command.execute failed: internal: network down', + text: 'command.execute failed: gateway/internal: network down', }]) }) diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index 6d4ee7a130..03940533c5 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -15,8 +15,9 @@ export function imageSizeText(bytes: number): string { } /** - * Product copy for a host attachment rejection (the `attachment-error` - * `details.reason`). User-solvable reasons name the limit and the way out; + * Product copy for a host attachment rejection (the `details.reason` of + * `session/attachment-invalid` or `subagent/attachment-unsupported`). + * User-solvable reasons name the limit and the way out; * reasons the user cannot act on fold into one send-failed line carrying the * reason code for a bug report. * @param t - the conversation-namespace translate. diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2cddf23feb..6c7132425e 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -186,10 +186,10 @@ export class InputHub implements SessionInputResolver { /** * Steer every still-pending queued message into the running turn, in FIFO * order — the same strict-steer operation as the queue dock's per-row - * button. A turn closing mid-way (`steer-unavailable`) or a row already - * claimed by the agent (`queue-item-not-found`) converges silently, while a + * button. A turn closing mid-way (`session/steer-unavailable`) or a row already + * claimed by the agent (`session/queue-item-not-found`) converges silently, while a * genuine failure surfaces as one composer notice. Repeated triggers - * (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found` + * (e.g. two rapid empty-draft chords) rely on that `session/queue-item-not-found` * convergence: the snapshot may still list a row the host already steered, * and the duplicate strict steer is a silent no-op. * @param session - the addressed host session. @@ -201,7 +201,7 @@ export class InputHub implements SessionInputResolver { for (const item of queued) { const result = await session.updateQueue(item.id, { kind: 'steer' }) if (result.ok) continue - if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return + if (result.error.code === 'session/steer-unavailable' || result.error.code === 'session/queue-item-not-found') return shell.notify('error', this.t('queue.steerFailed')) return } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 2fb0c99b51..fd59a492f0 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -315,7 +315,7 @@ export class ConversationController extends Service implements IConversation { if (!result.ok) { if ( action.kind === 'steer' - && (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') + && (result.error.code === 'session/steer-unavailable' || result.error.code === 'session/queue-item-not-found') ) return throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index d6126acdfd..d4c06d8bf3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -88,13 +88,15 @@ export function InputBar({ // and the user resubmits. A remount over a session whose machine still holds // an unresolved promptError deliberately re-announces it once — the failure // is still pending, and a transient banner is its only surface. Attachment - // rejections show product copy keyed by the wire reason; other codes are - // developer-facing and keep the raw message plus code. + // rejections show product copy keyed by the wire reason — whichever domain + // refused them; other codes are developer-facing and keep the raw message + // plus code. useEffect(() => { if (promptError === null) return - showToast(promptError.error.code === 'attachment-error' - ? attachmentErrorText(t, promptError.error.details.reason, imageLimits) - : `${promptError.error.message} (${promptError.error.code})`) + const { error } = promptError + showToast(error.code === 'session/attachment-invalid' || error.code === 'subagent/attachment-unsupported' + ? attachmentErrorText(t, error.details.reason, imageLimits) + : `${error.message} (${error.code})`) }, [promptError, showToast, t, imageLimits]) useEffect(() => { if (notice?.level === 'error') showToast(notice.text) diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index b4103dbda4..fb47038965 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -14,6 +14,7 @@ import { import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createConversationStore } from '../src/client/stores.ts' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' usePinnedBrowserLanguages('zh-CN') @@ -112,7 +113,7 @@ describe('Conversation inject API', () => { }) b.sessionFake.prompt.mockResolvedValueOnce({ - ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, + ok: false, error: new RemoteError('session/agent-busy', 'busy', { reason: 'busy' }), }) actions.setDraft('retry me') actions.submit() @@ -128,7 +129,7 @@ describe('Conversation inject API', () => { expect(b.inputApi(ROOT).state).toBe(state) b.sessionFake.cancel.mockResolvedValueOnce({ - ok: false, error: { code: 'internal', message: 'stop failed', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'stop failed', {}), }) b.composerApi(ROOT).stop!() await vi.waitFor(() => { expect(b.sessionFake.cancel).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index ff4960f37c..fd99320d93 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -6,7 +6,9 @@ import { useState } from 'react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { + RemoteError, SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope, +} from '@deepseek-ai/dsh-client-test-runtime' import { InputHub } from '../src/client/input/hub.ts' import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -178,7 +180,8 @@ describe('prompt rejection through the assembled composer', () => { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) const prompt = vi.fn(async () => ({ - ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, + ok: false, + error: new RemoteError('session/agent-busy', 'prompt rejected before acceptance', { reason: 'busy' }), })) await runtime.sessions.add({ id: SID, @@ -201,11 +204,11 @@ describe('prompt rejection through the assembled composer', () => { await runtime.sessions.updateSessionSnapshot(SID, (draft) => { draft.promptError = { op: 'send', - error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } }, + error: new RemoteError('session/agent-busy', 'prompt rejected before acceptance', { reason: 'busy' }), } }) const alert = await view.findByRole('alert') - expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)') + expect(alert.textContent).toContain('prompt rejected before acceptance (session/agent-busy)') await waitFor(() => { expect(shell.snapshot.draft).toBe('do not lose this') }) diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index fea7fa315b..44022cb553 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -1,12 +1,11 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx' import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx' import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts' diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index dee570b52a..54be8975c3 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { HistoricalImageCache } from '../src/client/conversation/historical-images.ts' describe('HistoricalImageCache', () => { @@ -68,7 +68,7 @@ describe('HistoricalImageCache', () => { session: { readAttachment: () => Promise.resolve({ ok: false, - error: { code: 'attachment-error', message: 'missing', details: {} }, + error: new RemoteError('session/attachment-invalid', 'missing', { reason: 'missing' }), } as never), }, }) @@ -78,7 +78,7 @@ describe('HistoricalImageCache', () => { } as const expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true) - await expect(cache.resolve(sessionId, attachment)).rejects.toThrow('attachment-error: missing') + await expect(cache.resolve(sessionId, attachment)).rejects.toThrow('attachment-invalid: missing') expect(cache.peek(sessionId, attachment)).toBeUndefined() expect(revoked).toHaveBeenCalledWith('blob:seeded') await runtime.dispose() diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 9becd7f109..3cdfce1abf 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -11,10 +11,12 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { $getRoot, $isTextNode } from 'lexical' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { + bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError, + sessionSnapshot as sessionFixture, +} from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' -import { conversationSnapshot as conversationFixture, makeTranslate, sessionSnapshot as sessionFixture } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -340,7 +342,7 @@ describe('image draft rail', () => { it('announces server attachment rejections as product copy, other codes as developer text', () => { const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({ op: 'send', - error: { code: 'attachment-error', message: 'raw wire text', details: { reason } }, + error: new RemoteError('session/attachment-invalid', 'raw wire text', { reason }), }) const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') }) expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型') @@ -348,10 +350,22 @@ describe('image draft rail', () => { const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') }) expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败(ATTACHMENT_NOT_REFERENCED)') cleanup() - const other = bench({ - promptError: { op: 'send', error: { code: 'internal', message: 'boom', details: {} } }, + // A subagent turn refuses images under its own code; the reason keys the + // same product copy, because the user cannot act on which domain refused. + const subagent = bench({ + promptError: { + op: 'send', + error: new RemoteError('subagent/attachment-unsupported', 'raw wire text', { + childSessionId: SID, reason: 'SUBAGENT_IMAGE_UNSUPPORTED', + }), + }, }) - expect(other.view.getByRole('alert').textContent).toContain('boom (internal)') + expect(subagent.view.getByRole('alert').textContent).toContain('子智能体会话暂不支持图片') + cleanup() + const other = bench({ + promptError: { op: 'send', error: new RemoteError('gateway/internal', 'boom', {}) }, + }) + expect(other.view.getByRole('alert').textContent).toContain('boom (gateway/internal)') }) it('marks the attachment slot unavailable while the composer is locked', () => { @@ -1224,10 +1238,12 @@ describe('strips and variants', () => { it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => { vi.useFakeTimers() try { - const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } }) + const send = bench({ + promptError: { op: 'send', error: new RemoteError('session/agent-busy', 'boom', { reason: 'boom' }) }, + }) // The toast body-portals (transformed ancestors must not trap it), so // queries go through the view's document-bound helpers. - expect(send.view.getByRole('alert').textContent).toContain('boom (agent-busy)') + expect(send.view.getByRole('alert').textContent).toContain('boom (session/agent-busy)') expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull() act(() => { vi.advanceTimersByTime(4000) }) expect(send.view.queryByRole('alert')).toBeNull() diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index a6582d42b8..9fbcaf92aa 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -5,7 +5,7 @@ // tag probe). import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' @@ -52,33 +52,33 @@ describe('ConversationController', () => { it('folds Session business failures into callback rejections', async () => { const b = await bench() - b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never) - await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy') - b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never) - await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope') + b.prompt.mockResolvedValueOnce({ ok: false, error: new RemoteError('session/agent-busy', 'busy', { reason: 'busy' }) } as never) + await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: session/agent-busy: busy') + b.cancel.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'nope', {}) } as never) + await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: gateway/internal: nope') b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'internal', message: 'broken', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'broken', {}), } as never) await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })) - .rejects.toThrow('conversation.updateQueue failed: internal: broken') + .rejects.toThrow('conversation.updateQueue failed: gateway/internal: broken') await b.runtime.dispose() }) it('treats strict-steer races as converged Queue delivery', async () => { const b = await bench() b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })).resolves.toBeUndefined() b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) await expect(b.scoped.updateQueue('item-2' as never, { kind: 'steer' })).resolves.toBeUndefined() b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) await expect(b.scoped.updateQueue('item-3' as never, { kind: 'remove' })) - .rejects.toThrow('conversation.updateQueue failed: queue-item-not-found: claimed') + .rejects.toThrow('conversation.updateQueue failed: session/queue-item-not-found: claimed') await b.runtime.dispose() }) @@ -241,7 +241,7 @@ describe('sendSession submission echo', () => { const b = await echoBench() try { b.prompt.mockResolvedValueOnce({ - ok: false, error: { code: 'attachment-error', message: 'nope', details: {} }, + ok: false, error: new RemoteError('session/attachment-invalid', 'nope', { reason: 'nope' }), } as never) const [attachment] = b.root.createDraftImages([ new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), @@ -399,7 +399,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { }) // The turn closes before the second row: the flush stops, silently. b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) b.shell.steerQueue() await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) @@ -411,7 +411,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { draft.queue = [row('q-3')] }) b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as QueuedMessage['id'] }), } as never) b.shell.steerQueue() await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) }) @@ -425,7 +425,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { draft.queue = [row('q-1'), row('q-2')] }) b.updateQueue.mockResolvedValueOnce({ - ok: false, error: { code: 'internal', message: 'broken', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'broken', {}), } as never) b.shell.steerQueue() await vi.waitFor(() => { diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index ed8a222e4f..1d3f4159f0 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -7,7 +7,7 @@ import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-ses import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, makeTranslate, sessionSnapshot as sessionFixture, + bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -466,7 +466,7 @@ describe('ConversationRoot resident composer', () => { awaitingFirstTurn: true, promptError: { op: 'send', - error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, + error: new RemoteError('session/agent-busy', 'busy', { reason: 'busy' }), }, }) diff --git a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx index a1853ef0c7..e79081b003 100644 --- a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx @@ -6,10 +6,9 @@ */ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx' import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx' diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 3be17e7da4..d66a505ff2 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -7,7 +7,6 @@ * composing this plugin out of cordis.yml removes both surfaces entirely; * the owning view renders an empty chain and inert prose at zero cost. */ -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' @@ -32,14 +31,13 @@ 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', 'remote', 'remote.session'] +export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session'] /** * Client plugin body: register the dictionaries and the turn-tail entry. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const connection = ctx.get('connection') as ConnectionHandle const workspacePathOpen = createSnapshotStore(undefined) let requestedWorkspacePathOpen = false let capabilityRevision = 0 @@ -50,8 +48,6 @@ export function apply(ctx: ClientContext): void { 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 @@ -77,7 +73,7 @@ export function apply(ctx: ClientContext): void { select: selectProducedFiles, locale: NS, inject: () => ({ - isLoopback: connection.isLoopback, + isLoopback: ctx.remote.$host.isLoopback, ensureWorkspacePathOpen, hooks: { workspacePathOpen }, }), diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 5a0043971e..56821c7ecd 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -20,7 +20,7 @@ import type { import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps, } from '../src/client/ProducedFiles.tsx' @@ -578,16 +578,15 @@ describe('plugin registration', () => { name: 'root', children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) - const generation = { getSnapshot: () => undefined, subscribe: () => () => {} } - ctx.provide('connection', { - isLoopback: false, - generation, - } as never) // ui-theme's Appearance row binds a durable scope through these two. const session = { canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }), } - ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote', { + $on: () => () => {}, + $host: { home: undefined, isLoopback: false }, + 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() @@ -636,20 +635,20 @@ describe('plugin registration', () => { 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 staleFailure = Promise.withResolvers<{ ok: false; error: RemoteError }>() const capability = vi.fn() .mockReturnValueOnce(first.promise) .mockReturnValueOnce(second.promise) .mockReturnValueOnce(staleFailure.promise) - .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'offline', {}) }) const session = { canOpenWorkspacePath: capability } - ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote', { + $on: () => () => {}, + $host: { home: undefined, isLoopback: true }, + 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() @@ -671,7 +670,7 @@ describe('plugin registration', () => { ctx.emit('connection/reset') ctx.emit('connection/reset') - staleFailure.reject(new Error('stale offline')) + staleFailure.resolve({ ok: false, error: new RemoteError('gateway/internal', 'stale offline', {}) }) await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) }) await fiber.dispose() }) diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 2fec719e06..c2c98cada5 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -75,7 +75,7 @@ export function apply(ctx: ClientContext): void { const noCurrentGoal: GoalActionResult = { ok: false, - error: { code: 'no-current-goal', message: 'no current goal to mutate', details: {} }, + error: { code: 'no-current-goal', message: 'no current goal to mutate' }, } ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({ diff --git a/packages/client/ui-goal/src/client/slots.ts b/packages/client/ui-goal/src/client/slots.ts index 66101c601d..a343b44fc9 100644 --- a/packages/client/ui-goal/src/client/slots.ts +++ b/packages/client/ui-goal/src/client/slots.ts @@ -7,14 +7,23 @@ * (callbacks from inject, live state from useProjection). */ -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' + +/** + * The one failure the strip reports without a wire call: the session projects + * no goal, so no CAS ref exists to address a mutation to. + */ +export interface GoalLocalFailure { + readonly ok: false + readonly error: { readonly code: 'no-current-goal'; readonly message: string } +} /** * 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 +export type GoalActionResult = RemoteResult | GoalLocalFailure /** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */ export interface GoalBarActions { diff --git a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx index cb2cf0a4be..eb4618eea4 100644 --- a/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.client.spec.tsx @@ -19,7 +19,8 @@ import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { GoalBarActions } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' @@ -49,7 +50,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; details: object } + failWith?: RemoteFailure } = {}) { const ctx = new Context() const calls: { method: string; args: unknown[] }[] = [] @@ -183,17 +184,20 @@ 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', details: {} } }) + expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } }) } expect(b.calls).toHaveLength(0) } }) it('forwards a Remote failure to the strip verbatim', async () => { - const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision', details: {} } }) + const b = await bench({ + projection: makeProjection(), + failWith: new RemoteError('gateway/internal', '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', details: {} } }) + expect(await verbs.onEdit('x')).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'stale revision' } }) }) it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { diff --git a/packages/client/ui-goal/tests/goalbar.client.spec.tsx b/packages/client/ui-goal/tests/goalbar.client.spec.tsx index 064b34ea9f..c4518fb757 100644 --- a/packages/client/ui-goal/tests/goalbar.client.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.client.spec.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { GoalBar } from '../src/client/GoalBar.tsx' import type { GoalActionResult, GoalBarActions } from '../src/client/slots.ts' @@ -174,28 +174,32 @@ 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', details: {} } }) + actions.onEdit.mockResolvedValue({ + ok: false, error: new RemoteError('session/agent-busy', 'stale revision', { reason: 'stale revision' }), + }) render() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) const box = screen.getByRole('textbox', { name: '目标内容' }) fireEvent.change(box, { target: { value: 'retry this draft' } }) fireEvent.click(screen.getByRole('button', { name: '保存目标' })) - expect((await screen.findByRole('alert')).textContent).toBe('stale revision (agent-busy)') + expect((await screen.findByRole('alert')).textContent).toBe('stale revision (session/agent-busy)') expect(screen.getByRole('textbox', { name: '目标内容' })).toHaveProperty('value', 'retry this draft') }) 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', details: {} } }) + actions.onResume.mockResolvedValue({ ok: false, error: new RemoteError('gateway/internal', 'resume failed', {}) }) const { rerender } = render() fireEvent.click(screen.getByRole('button', { name: '恢复目标' })) - expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)') + expect((await screen.findByRole('alert')).textContent).toBe('resume failed (gateway/internal)') - actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed', details: {} } }) + actions.onClear.mockResolvedValue({ + ok: false, error: new RemoteError('session/agent-busy', 'clear failed', { reason: 'clear failed' }), + }) rerender() fireEvent.click(screen.getByRole('button', { name: '清除目标' })) - expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)') + expect((await screen.findByRole('alert')).textContent).toBe('clear failed (session/agent-busy)') expect(screen.getByText('Ship the redesign')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '清除目标' })) await waitFor(() => { expect(actions.onClear).toHaveBeenCalledTimes(2) }) diff --git a/packages/client/ui-message-feedback/src/client/controller.ts b/packages/client/ui-message-feedback/src/client/controller.ts index 8e793bc077..e02a2cccf4 100644 --- a/packages/client/ui-message-feedback/src/client/controller.ts +++ b/packages/client/ui-message-feedback/src/client/controller.ts @@ -7,40 +7,15 @@ * @module @deepseek-ai/dsh-client-ui-message-feedback/client/controller */ -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - MessageFeedbackDeleteResult, MessageFeedbackItem, - MessageFeedbackListResult, - MessageFeedbackPutResult, MessageFeedbackRating, } from '@deepseek-ai/dsh-message-feedback/types' -/** - * The three Remote calls this controller needs. The generated face wraps every - * business result in {@link RemoteResult}: a carrier failure arrives as the - * `ok: false` branch rather than a rejection, so this controller reads one - * envelope and never wraps a call to recover a transport error. - */ -export interface MessageFeedbackRemote { - list: (request: { sessionId: SessionId }) => Promise> - put: (request: { - sessionId: SessionId - messageId: MessageId - rating: MessageFeedbackRating - note?: string - ifVersion: MessageFeedbackItem['version'] | null - }) => Promise> - delete: (request: { - sessionId: SessionId - messageId: MessageId - ifVersion: MessageFeedbackItem['version'] - }) => Promise> -} - /** Load state of the one list read that seeds every per-message control. */ export type MessageFeedbackStatus = 'cold' | 'loading' | 'ready' | 'error' @@ -111,11 +86,11 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.remote.put({ + const carried = await this.ctx.remote.messageFeedback.put({ sessionId: this.sessionId, messageId, rating, @@ -265,7 +240,7 @@ export class MessageFeedbackController implements HostObservable { - const carried = await this.remote.delete({ + const carried = await this.ctx.remote.messageFeedback.delete({ sessionId: this.sessionId, messageId, ifVersion: observed.version, @@ -288,34 +263,26 @@ export class MessageFeedbackController implements HostObservable { - try { - const carried = await this.remote.list({ sessionId: this.sessionId }) - if (this.disposed) return OK - if (!carried.ok) { - this.publish({ status: 'error', items: this.view.items, error: carried.error.message }) - return carrierFailure(carried.error) - } - const result = carried.value - if (!result.ok) { - this.publish({ status: 'error', items: this.view.items, error: describe(result.error.code) }) - return fail(result.error.code) - } - const items = new Map() - for (const item of result.value.items) items.set(item.messageId, item) - this.publish({ status: 'ready', items, error: null }) - return OK - } catch (error) { - if (this.disposed) return OK - const message = error instanceof Error ? error.message : String(error) - this.publish({ status: 'error', items: this.view.items, error: message }) - return { ok: false, error: { code: 'transport', message } } + const carried = await this.ctx.remote.messageFeedback.list({ sessionId: this.sessionId }) + if (this.disposed) return OK + if (!carried.ok) { + this.publish({ status: 'error', items: this.view.items, error: carried.error.message }) + return carrierFailure(carried.error) } + const result = carried.value + if (!result.ok) { + this.publish({ status: 'error', items: this.view.items, error: describe(result.error.code) }) + return fail(result.error.code) + } + const items = new Map() + for (const item of result.value.items) items.set(item.messageId, item) + this.publish({ status: 'ready', items, error: null }) + return OK } /** * Serialize one mutation behind this Session's prior mutation so queued - * operations always compare against the committed version, and translate a - * transport throw into the same settled shape the controls already render. + * operations always compare against the committed version. */ private mutate( operation: () => Promise, @@ -331,17 +298,7 @@ export class MessageFeedbackController implements HostObservable { let controller = controllers.get(sessionId) if (controller === undefined) { - controller = new MessageFeedbackController(ctx.remote.messageFeedback, sessionId) + controller = new MessageFeedbackController(ctx, sessionId) controllers.set(sessionId, controller) } return controller diff --git a/packages/client/ui-message-feedback/src/client/slots.ts b/packages/client/ui-message-feedback/src/client/slots.ts index 8fc7f9dde2..352ff56526 100644 --- a/packages/client/ui-message-feedback/src/client/slots.ts +++ b/packages/client/ui-message-feedback/src/client/slots.ts @@ -12,7 +12,7 @@ import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types' // Type-only: pulls this package's LocaleNamespaceMap merge (the 'feedback' seat). import type {} from './locales.ts' diff --git a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx index bd894ebacf..96d8b21a44 100644 --- a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx @@ -14,7 +14,7 @@ import { cleanup } from '@testing-library/react' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types' import type { MessageFeedbackInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' diff --git a/packages/client/ui-message-feedback/tests/controller.client.spec.ts b/packages/client/ui-message-feedback/tests/controller.client.spec.ts index 7f7c9cdecb..1c389d1403 100644 --- a/packages/client/ui-message-feedback/tests/controller.client.spec.ts +++ b/packages/client/ui-message-feedback/tests/controller.client.spec.ts @@ -6,11 +6,13 @@ * serialize per Session, and a disposed controller stops publishing. */ import { describe, expect, it, vi } from 'vitest' -import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { MessageId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { MessageFeedbackItem, MessageFeedbackVersion, } from '@deepseek-ai/dsh-message-feedback/types' -import { MessageFeedbackController, type MessageFeedbackRemote } from '../src/client/controller.ts' +import { MessageFeedbackController } from '../src/client/controller.ts' const SESSION = 's-1' as SessionId const MSG = 'm-1' as MessageId @@ -37,10 +39,10 @@ type Script = { } /** - * A recording fake Remote. Scripts return the *business* result; this wraps it + * A recording fake context. Scripts return the *business* result; this wraps it * in the carrier envelope the generated face uses, so specs stay readable. A - * script may also return an already-enveloped `{ok:false,error:{code,message, - * details}}` to exercise a carrier failure. + * script may also return an already-enveloped `{ok:false,error:RemoteError}` to + * exercise a carrier failure. */ function fakeRemote(script: Script = {}) { const calls: { method: string; request: unknown }[] = [] @@ -53,21 +55,25 @@ function fakeRemote(script: Script = {}) { const business = real === undefined ? Promise.resolve(fallback) : real(request) return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise } - const remote = { - list: record('list', script.list, { ok: true, value: { items: [] } }), - put: record('put', script.put, { ok: true, value: item() }), - delete: record('delete', script.delete, { ok: true, value: { absent: true } }), - } as unknown as MessageFeedbackRemote - return { remote, calls } + const ctx = { + remote: { + messageFeedback: { + list: record('list', script.list, { ok: true, value: { items: [] } }), + put: record('put', script.put, { ok: true, value: item() }), + delete: record('delete', script.delete, { ok: true, value: { absent: true } }), + }, + }, + } as unknown as ClientContext + return { ctx, calls } } describe('MessageFeedbackController', () => { it('seeds the view from one list read and keys items by message id', async () => { const seeded = item({ note: 'good' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(controller.getSnapshot().status).toBe('cold') expect(await controller.ensure()).toEqual({ ok: true }) @@ -79,8 +85,8 @@ describe('MessageFeedbackController', () => { }) it('collapses concurrent loads onto one in-flight read', async () => { - const { remote, calls } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx, calls } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()]) @@ -90,13 +96,13 @@ describe('MessageFeedbackController', () => { it('sends ifVersion null for a first rating and the observed version afterwards', async () => { const first = item({ version: version('v1') }) const second = item({ version: version('v2'), rating: 'negative' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ put: request => Promise.resolve({ ok: true, value: (request as { rating: string }).rating === 'positive' ? first : second, }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true }) expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true }) @@ -108,8 +114,8 @@ describe('MessageFeedbackController', () => { }) it('forwards an optional note and omits the field when absent', async () => { - const { remote, calls } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx, calls } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) await controller.rate(MSG, 'positive', 'helpful') await controller.rate(OTHER, 'negative') @@ -121,13 +127,13 @@ describe('MessageFeedbackController', () => { it('reconciles a version conflict from the authoritative item without refetching', async () => { const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ put: () => Promise.resolve({ ok: false, error: { code: 'version-conflict', current: authoritative }, }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, @@ -139,14 +145,14 @@ describe('MessageFeedbackController', () => { }) it('drops the local item when a conflict reports the feedback is gone', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), delete: () => Promise.resolve({ ok: false, error: { code: 'version-conflict', current: null }, }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'version-conflict' } }) @@ -154,10 +160,10 @@ describe('MessageFeedbackController', () => { }) it('deletes with the observed version and removes the item on success', async () => { - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() expect(await controller.clear(MSG)).toEqual({ ok: true }) @@ -168,8 +174,8 @@ describe('MessageFeedbackController', () => { }) it('treats clearing an unrated message as already satisfied without a call', async () => { - const { remote, calls } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx, calls } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.clear(MSG)).toEqual({ ok: true }) expect(calls.filter(call => call.method === 'delete')).toHaveLength(0) @@ -180,7 +186,7 @@ describe('MessageFeedbackController', () => { let overlapped = false const versions = [version('v1'), version('v2')] let index = 0 - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ put: async () => { inFlight += 1 if (inFlight > 1) overlapped = true @@ -191,7 +197,7 @@ describe('MessageFeedbackController', () => { return { ok: true, value: item({ version: next }) } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')]) @@ -202,10 +208,10 @@ describe('MessageFeedbackController', () => { }) it('publishes an error status when the list read is rejected by the Host', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) expect(controller.getSnapshot()).toMatchObject({ @@ -214,31 +220,9 @@ describe('MessageFeedbackController', () => { }) }) - it('settles a transport throw as a result instead of rejecting', async () => { - const { remote } = fakeRemote({ list: () => Promise.reject(new Error('socket closed')) }) - const controller = new MessageFeedbackController(remote, SESSION) - - expect(await controller.ensure()).toEqual({ - ok: false, - error: { code: 'transport', message: 'socket closed' }, - }) - expect(controller.getSnapshot().status).toBe('error') - }) - - it('settles a mutation transport throw without corrupting the view', async () => { - const { remote } = fakeRemote({ put: () => Promise.reject(new Error('socket closed')) }) - const controller = new MessageFeedbackController(remote, SESSION) - - expect(await controller.rate(MSG, 'positive')).toEqual({ - ok: false, - error: { code: 'transport', message: 'socket closed' }, - }) - expect(controller.getSnapshot().items.has(MSG)).toBe(false) - }) - it('notifies subscribers on publication and stops after unsubscribe', async () => { - const { remote } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) const listener = vi.fn() const unsubscribe = controller.subscribe(listener) @@ -252,8 +236,8 @@ describe('MessageFeedbackController', () => { }) it('contains a throwing subscriber at the observable boundary', async () => { - const { remote } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) controller.subscribe(() => { throw new Error('subscriber exploded') }) const healthy = vi.fn() @@ -267,8 +251,8 @@ describe('MessageFeedbackController', () => { }) it('refuses mutations and stops publishing once disposed', async () => { - const { remote, calls } = fakeRemote() - const controller = new MessageFeedbackController(remote, SESSION) + const { ctx, calls } = fakeRemote() + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -289,20 +273,20 @@ describe('MessageFeedbackController', () => { ['note-too-large', 'the note is too long'], ] as const for (const [code, message] of codes) { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } }) expect(controller.getSnapshot().error).toBe(message) } }) it('falls back to the raw code for an unrecognized failure', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } }) expect(controller.getSnapshot().error).toBe('brand-new-code') @@ -311,13 +295,13 @@ describe('MessageFeedbackController', () => { it('publishes nothing when the list settles after disposal', async () => { let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: async () => { await gate return { ok: true, value: { items: [item()] } } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) const pending = controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -330,47 +314,11 @@ describe('MessageFeedbackController', () => { expect(listener).not.toHaveBeenCalled() }) - it('swallows a rejected list that settles after disposal', async () => { - let reject = (): void => {} - const gate = new Promise((_resolve, rejectFn) => { reject = () => { rejectFn(new Error('late')) } }) - const { remote } = fakeRemote({ list: () => gate }) - const controller = new MessageFeedbackController(remote, SESSION) - const pending = controller.ensure() - - controller.dispose() - reject() - - expect(await pending).toEqual({ ok: true }) - expect(controller.getSnapshot().status).not.toBe('error') - }) - - it('preserves a non-Error list rejection as a diagnostic string', async () => { - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test. - const { remote } = fakeRemote({ list: () => Promise.reject('socket string') }) - const controller = new MessageFeedbackController(remote, SESSION) - - expect(await controller.ensure()).toEqual({ - ok: false, - error: { code: 'transport', message: 'socket string' }, - }) - }) - - it('preserves a non-Error mutation rejection as a diagnostic string', async () => { - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test. - const { remote } = fakeRemote({ put: () => Promise.reject('nope') }) - const controller = new MessageFeedbackController(remote, SESSION) - - expect(await controller.rate(MSG, 'positive')).toEqual({ - ok: false, - error: { code: 'transport', message: 'nope' }, - }) - }) - it('propagates a failed load to a queued mutation without calling the wire', async () => { - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, @@ -381,23 +329,23 @@ describe('MessageFeedbackController', () => { it('keeps a later mutation running after an earlier one settles as a failure', async () => { let first = true - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ put: () => { if (first) { first = false - return Promise.reject(new Error('first blew up')) + return Promise.resolve({ ok: false, error: new RemoteError('gateway/internal', 'first blew up', {}) }) } return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) }) }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) const [a, b] = await Promise.all([ controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative'), ]) - expect(a).toMatchObject({ ok: false, error: { code: 'transport' } }) + expect(a).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) expect(b).toEqual({ ok: true }) expect(controller.getSnapshot().items.get(MSG)?.rating).toBe('negative') }) @@ -408,14 +356,14 @@ describe('MessageFeedbackController', () => { // fiber unloads, and its authoritative item must not be published. let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }), put: async () => { await gate return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() const listener = vi.fn() controller.subscribe(listener) @@ -432,14 +380,14 @@ describe('MessageFeedbackController', () => { it('drops a delete conflict reconciliation once disposed mid-flight', async () => { let release = (): void => {} const gate = new Promise((resolve) => { release = resolve }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), delete: async () => { await gate return { ok: false, error: { code: 'version-conflict', current: null } } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() const pending = controller.clear(MSG) @@ -455,11 +403,11 @@ describe('MessageFeedbackController', () => { it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => { const existing = item({ version: version('v3'), rating: 'positive' }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() expect(await controller.rate(MSG, 'negative', 'far too long')).toMatchObject({ @@ -471,11 +419,11 @@ describe('MessageFeedbackController', () => { it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => { const existing = item({ version: version('v4') }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [existing] } }), delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() expect(await controller.clear(MSG)).toMatchObject({ @@ -489,10 +437,10 @@ describe('MessageFeedbackController', () => { // Regression: a control that rendered before the first list read holds no // item, so it passes note=undefined; that must not erase the stored note. const stored = item({ version: version('v1'), rating: 'positive', note: 'keep me' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true }) @@ -503,10 +451,10 @@ describe('MessageFeedbackController', () => { it('toggle retracts when the committed rating already matches', async () => { const stored = item({ version: version('v1'), rating: 'positive' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true }) @@ -519,10 +467,10 @@ describe('MessageFeedbackController', () => { // The click lands before any list read: the cold view knows no item, yet the // stored rating matches, so the toggle must retract rather than re-put. const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(controller.getSnapshot().status).toBe('cold') expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true }) @@ -532,10 +480,10 @@ describe('MessageFeedbackController', () => { it('toggle replaces the opposite rating and carries the note forward', async () => { const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.toggle(MSG, 'negative')).toEqual({ ok: true }) @@ -545,10 +493,10 @@ describe('MessageFeedbackController', () => { it('clearNote drops the note and keeps the rating', async () => { const stored = item({ version: version('v1'), rating: 'negative', note: 'remove me' }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [stored] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.clearNote(MSG)).toEqual({ ok: true }) @@ -558,10 +506,10 @@ describe('MessageFeedbackController', () => { }) it('clearNote is a no-op when there is no note to drop', async () => { - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.clearNote(MSG)).toEqual({ ok: true }) expect(calls.filter(c => c.method === 'put')).toHaveLength(0) @@ -573,7 +521,7 @@ describe('MessageFeedbackController', () => { const order: string[] = [] let releasePut = (): void => {} const putGate = new Promise((r) => { releasePut = r }) - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => { order.push('list') return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }) @@ -585,7 +533,7 @@ describe('MessageFeedbackController', () => { return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() const rating = controller.rate(MSG, 'negative') @@ -605,14 +553,14 @@ describe('MessageFeedbackController', () => { const gate = new Promise((r) => { release = r }) let started = (): void => {} const listStarted = new Promise((r) => { started = r }) - const { remote, calls } = fakeRemote({ + const { ctx, calls } = fakeRemote({ list: async () => { started() await gate return { ok: true, value: { items: [] } } }, }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) const pending = controller.rate(MSG, 'positive') await listStarted @@ -626,49 +574,49 @@ describe('MessageFeedbackController', () => { it('renders a carrier failure from the Remote envelope', async () => { // The generated face folds transport faults into ok:false with a // RemoteFailure, so the controller reads them as values, not rejections. - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed', details: {} }, + error: new RemoteError('gateway/internal', 'socket closed', {}), }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.ensure()).toEqual({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed' }, + error: { code: 'gateway/internal', message: 'socket closed' }, }) expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' }) }) it('renders a carrier failure on a mutation without touching the view', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ put: () => Promise.resolve({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed', details: {} }, + error: new RemoteError('gateway/internal', 'socket closed', {}), }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) expect(await controller.rate(MSG, 'positive')).toEqual({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed' }, + error: { code: 'gateway/internal', message: 'socket closed' }, }) expect(controller.getSnapshot().items.has(MSG)).toBe(false) }) it('renders a carrier failure on a delete', async () => { - const { remote } = fakeRemote({ + const { ctx } = fakeRemote({ list: () => Promise.resolve({ ok: true, value: { items: [item()] } }), delete: () => Promise.resolve({ ok: false, - error: { code: 'carrier-closed', message: 'socket closed', details: {} }, + error: new RemoteError('gateway/internal', 'socket closed', {}), }), }) - const controller = new MessageFeedbackController(remote, SESSION) + const controller = new MessageFeedbackController(ctx, SESSION) await controller.ensure() - expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'carrier-closed' } }) + expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) expect(controller.getSnapshot().items.has(MSG)).toBe(true) }) }) diff --git a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx index 3c06dc3718..94987eb3db 100644 --- a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' +import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client' import type { MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion, } from '@deepseek-ai/dsh-message-feedback/types' diff --git a/packages/client/ui-model-selection/src/client/catalog.ts b/packages/client/ui-model-selection/src/client/catalog.ts index 5bfbcb2d1e..d5073d2ab0 100644 --- a/packages/client/ui-model-selection/src/client/catalog.ts +++ b/packages/client/ui-model-selection/src/client/catalog.ts @@ -1,6 +1,7 @@ /** One Host-generation model catalog shared by every Session selector. */ -import type { ClientRemote, ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' /** Observable lifecycle of the shared model catalog. */ @@ -22,8 +23,11 @@ export class ModelCatalogDirectory { private generation = 0 private inflight: Promise | undefined - /** @param session - Session Remote namespace carrying the Host-generation catalog. */ - constructor(private readonly session: Pick) {} + /** + * @param ctx - the providing plugin's context, whose `remote.session` + * namespace carries the Host-generation catalog. + */ + constructor(private readonly ctx: ClientContext) {} /** * Return the current generation's catalog, sharing its one in-flight load. @@ -38,7 +42,7 @@ export class ModelCatalogDirectory { draft.status = 'loading' draft.error = null }) - const operation = this.session.modelCatalog().then((response) => { + const operation = this.ctx.remote.session.modelCatalog().then((response) => { if (!response.ok) { throw new Error(`${response.error.code}: ${response.error.message}`) } diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index 2053d70b85..e72cdedbd3 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -48,7 +48,7 @@ export class ModelDirectoryResolver extends Service { constructor(ctx: Context, config: { blockReason: () => string }) { super(ctx, 'modelDirectories') this.blockReason = config.blockReason - this.catalog = new ModelCatalogDirectory(ctx.remote.session) + this.catalog = new ModelCatalogDirectory(ctx) void this.catalog.load().catch(() => { /* selectors expose the shared error */ }) ctx.on('connection/reset', () => { this.catalog.resetGeneration() diff --git a/packages/client/ui-model-selection/tests/catalog.client.spec.ts b/packages/client/ui-model-selection/tests/catalog.client.spec.ts index a9333113e8..f800266de2 100644 --- a/packages/client/ui-model-selection/tests/catalog.client.spec.ts +++ b/packages/client/ui-model-selection/tests/catalog.client.spec.ts @@ -1,4 +1,5 @@ -import type { ClientRemote, ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' +import type { ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { describe, expect, it, vi } from 'vitest' import { ModelCatalogDirectory } from '../src/client/catalog.ts' @@ -10,22 +11,23 @@ const catalog = (model: string): ModelCatalog => ({ }) function directory(models: () => Promise): ModelCatalogDirectory { - return new ModelCatalogDirectory({ modelCatalog: models } as unknown as ClientRemote['session']) + // The providing plugin's context, scripted down to the one method it calls. + return new ModelCatalogDirectory({ remote: { session: { modelCatalog: models } } } as never) } describe('ModelCatalogDirectory', () => { it('shares one failing request, exposes the RPC error, and permits a retry', async () => { const models = vi.fn() .mockResolvedValueOnce({ - ok: false, error: { code: 'unavailable', message: 'catalog offline', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'catalog offline', {}), }) .mockResolvedValueOnce({ ok: true, value: catalog('recovered') }) const subject = directory(models) const first = subject.load() expect(subject.load()).toBe(first) - await expect(first).rejects.toThrow('unavailable: catalog offline') - expect(subject.store.getSnapshot()).toMatchObject({ status: 'error', error: 'unavailable: catalog offline' }) + await expect(first).rejects.toThrow('gateway/internal: catalog offline') + expect(subject.store.getSnapshot()).toMatchObject({ status: 'error', error: 'gateway/internal: catalog offline' }) await expect(subject.load()).resolves.toEqual(catalog('recovered')) expect(models).toHaveBeenCalledTimes(2) }) diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index a46cdcf212..f30c3f7f3e 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -179,7 +179,7 @@ describe('ModelSelect reasoning effort', () => { }] const directory = createSnapshotStore(state({ groups })) const select = vi.fn(async () => { - directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' })) + directory.set(state({ groups, status: 'error', error: 'session/model-unavailable: session already contains images' })) return false }) render( { fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ })) const toast = await screen.findByRole('alert') - expect(toast.textContent).toContain('模型操作失败:model-unavailable: session already contains images') + expect(toast.textContent).toContain('模型操作失败:session/model-unavailable: session already contains images') // The selection failure does not render the in-menu load strip (no Retry). expect(screen.queryByRole('button', { name: '重试' })).toBeNull() }) diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index 3b2a405951..526d10efe6 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -117,7 +117,7 @@ export function apply(ctx: ClientContext): void { // The shared SettingsScope mirror updates after document commits and reconnects. const controller = new PermissionPresetSettingsController( - ctx.settingsScope.describe(), { settings: ctx.remote.settings }, ctx.settingsSchema) + ctx.settingsScope.describe(), ctx, ctx.settingsSchema) const load = (): Promise => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index a139df2a7c..a435273c27 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -6,12 +6,13 @@ * back into the mirror. */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-store' import type { - SchemaNode, SettingsDescribeFace, SettingsSchemaService, SettingsWireFace, + SchemaNode, SettingsDescribeFace, SettingsSchemaService, } from '@deepseek-ai/dsh-client-ui-settings/client' import { displayPermissionPreset } from './presentation.ts' @@ -94,12 +95,13 @@ export class PermissionPresetSettingsController { /** * @param describeFace - the shared mirror's read/fold face (descriptor and schema source). - * @param api - settings wire face for the `defaultPreset` write. + * @param ctx - the row plugin's context, whose `remote.settings` namespace + * carries the `defaultPreset` write. * @param schema - settings-owned schema operations. */ constructor( private readonly describeFace: SettingsDescribeFace, - private readonly api: SettingsWireFace, + private readonly ctx: ClientContext, private readonly schema: SettingsSchemaService, ) {} @@ -136,23 +138,26 @@ export class PermissionPresetSettingsController { draft.status = 'saving' draft.error = null }) + let response try { - const response = await this.api.settings.mutate( + response = await this.ctx.remote.settings.mutate( PERMISSION_SETTINGS_NS, [{ op: 'set', path: ['defaultPreset'], value: preset }], view.revision, ) - if (!response.ok) throw new Error(response.error.message) + } finally { + // Cleared before the fold below, whose publish reaches `derive` through + // this row's own subscription and is skipped while a save is pending. this.saving = false - if (this.disposed) return - // The mirror publish reaches this row's own subscription, so the fold - // is also what republishes the accepted value here. - this.describeFace.acceptView(response.value) - } catch (error) { - this.saving = false - if (this.disposed) return - this.fail(error) } + if (this.disposed) return + if (!response.ok) { + this.fail(response.error) + return + } + // The mirror publish reaches this row's own subscription, so the fold + // is also what republishes the accepted value here. + this.describeFace.acceptView(response.value) } /** Stop following the mirror; later publishes leave the snapshot alone. */ diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index 1d43249c75..c3fd611b99 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -48,17 +48,6 @@ async function bench() { 'settings.general.item': { kind: 'list', scope: 'root' }, }, } as never, () => null) - ctx.provide('connection', { - api: { - settings: { - describe: () => Promise.resolve({ - rpcId: 'describe', - result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }, - }), - mutate: () => Promise.reject(new Error('settings mutation is not exercised')), - }, - }, - } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() let decoration: CommandDecoration | undefined ctx.provide('commandUi', { @@ -81,7 +70,7 @@ async function bench() { commands.push(line) return Promise.resolve(commandResult.ok ? { ok: true as const, value: { matched: commandResult.matched ?? true } } - : { ok: false as const, error: { code: 'internal', message: 'boom' } }) + : { ok: false as const, error: { code: 'gateway/internal', message: 'boom' } }) }, }) ctx.provide('sessions', { diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index de7f6d4411..11e01893a9 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' @@ -12,10 +12,10 @@ import { PermissionPresetSettingsController } from '../src/client/settings-store const schema = new SettingsSchemaService(new Context()) -/** Controller over a real mirror derived from the same fake wire. */ -function derivedController(api: { settings: object }) { - const wire = api as never - return new PermissionPresetSettingsController(new SettingsDescribeMirror(wire), wire, schema) +/** Controller over a real mirror derived from the same scripted context. */ +function derivedController(remote: { settings: object }) { + const ctx = { remote } as never + return new PermissionPresetSettingsController(new SettingsDescribeMirror(ctx), ctx, schema) } afterEach(cleanup) @@ -155,7 +155,9 @@ describe('PermissionRow', () => { describe: () => describe.promise, mutate: () => Promise.resolve({ ok: false as const, - error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, + error: new RemoteError('settings/conflict', 'changed elsewhere', { + ns: 'permission', expected: 1, actual: 2, + }), }), }, }) diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index b607bcea99..c999d9ee1a 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { PermissionPresetSettingsController, permissionDefaultOf, } from '../src/client/settings-store.ts' @@ -40,11 +41,11 @@ function ok(value: T) { return { ok: true as const, value } } -/** The permission controller over a real mirror and one fake wire. */ +/** The permission controller over a real mirror and one scripted context. */ function permissionController(api: object) { - const wire = { settings: api } as never - const mirror = new SettingsDescribeMirror(wire) - return { mirror, controller: new PermissionPresetSettingsController(mirror, wire, schema) } + const ctx = { remote: { settings: api } } as never + const mirror = new SettingsDescribeMirror(ctx) + return { mirror, controller: new PermissionPresetSettingsController(mirror, ctx, schema) } } describe('permission settings store', () => { @@ -142,7 +143,7 @@ describe('permission settings store', () => { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => Promise.resolve({ ok: false as const, - error: { code: 'settings-conflict', message: 'stale', details: {} }, + error: new RemoteError('settings/conflict', 'stale', { ns: 'permission', expected: 1, actual: 2 }), }), }).controller await failing.load() @@ -170,7 +171,7 @@ describe('permission settings store', () => { const rejected = permissionController({ describe: () => Promise.resolve({ ok: false as const, - error: { code: 'internal', message: 'offline', details: {} }, + error: new RemoteError('gateway/internal', 'offline', {}), }), mutate, }).controller @@ -186,16 +187,18 @@ describe('permission settings store', () => { await thrown.load() expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) - const wire = { - settings: { - describe: () => Promise.resolve(ok({ - writable: true, hasDocument: false, namespaces: [view('read-only')], - })), - mutate, + const ctx = { + remote: { + settings: { + describe: () => Promise.resolve(ok({ + writable: true, hasDocument: false, namespaces: [view('read-only')], + })), + mutate, + }, }, } as never - const mirror = new SettingsDescribeMirror(wire) - const malformed = new PermissionPresetSettingsController(mirror, wire, { + const mirror = new SettingsDescribeMirror(ctx) + const malformed = new PermissionPresetSettingsController(mirror, ctx, { rehydrate: () => { throw 'schema disconnected' }, } as never) await malformed.load() @@ -207,9 +210,9 @@ describe('permission settings store', () => { it('hides the row in a remote browser instead of loading forever', async () => { const describeCall = vi.fn() const mutate = vi.fn() - const wire = { settings: { describe: describeCall, mutate } } as never - const mirror = new SettingsDescribeMirror(wire, 'memory') - const controller = new PermissionPresetSettingsController(mirror, wire, schema) + const ctx = { remote: { settings: { describe: describeCall, mutate } } } as never + const mirror = new SettingsDescribeMirror(ctx, 'memory') + const controller = new PermissionPresetSettingsController(mirror, ctx, schema) await controller.load() expect(controller.store.getSnapshot().status).toBe('unavailable') await controller.select('workspace-write') @@ -265,15 +268,20 @@ describe('permission settings store', () => { await saving expect(active.store.getSnapshot().status).toBe('saving') - const rejectedMutation = Promise.withResolvers>>() + const refusedMutation = Promise.withResolvers< + ReturnType> | { ok: false; error: RemoteError } + >() const { controller: disposedWrite } = permissionController({ describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), - mutate: () => rejectedMutation.promise, + mutate: () => refusedMutation.promise, }) await disposedWrite.load() const writing = disposedWrite.select('workspace-write') disposedWrite.dispose() - rejectedMutation.reject(new Error('late write')) + refusedMutation.resolve({ + ok: false, + error: new RemoteError('settings/conflict', 'late write', { ns: 'permission', expected: 1, actual: 2 }), + }) await writing expect(disposedWrite.store.getSnapshot().status).toBe('saving') }) diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index 9fe26757fa..ee5b790a79 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { PlanChip } from '../src/client/PlanModeControl.tsx' import type { PlanChipInjected } from '../src/client/index.ts' import { apply, inject } from '../src/client/index.ts' @@ -74,9 +75,9 @@ describe('ui-plan browser apply', () => { // reports the RPC failure in its error branch. b.execute.mockResolvedValueOnce({ ok: false, - error: { code: 'session-not-found', message: 'gone', details: {} }, + error: new RemoteError('session/not-found', 'gone', { sessionId: SID }), } as never) - await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)') + await expect(injected.exitPlanMode()).resolves.toBe('gone (session/not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. b.execute.mockResolvedValueOnce({ ok: true, value: undefined } as never) diff --git a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx index 3bc2dc9a3f..1249e89732 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx @@ -8,10 +8,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/client' import { PlanChip, type PlanChipProps } from '../src/client/PlanModeControl.tsx' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 70928a652c..b0b05a0aa1 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 09ecf5aaa9..3728fb7562 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -17,7 +17,6 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives' import type { ClientSessionContext, InputTriggerCrumb, InputTriggerServiceContract, InputTriggerSource, @@ -30,7 +29,7 @@ import { en, NS, zh, type ReferenceKey } from './locales.ts' /** Required services: the trigger registry, the Remote namespaces, and the copy. */ export const inject = [ - 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', + 'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', ] @@ -41,30 +40,25 @@ export const inject = [ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') const t = ctx.locale.bind(NS) - const connection = ctx.get('connection') as ConnectionHandle const sessions = ctx.get('sessions') as ISessions const source: InputTriggerSource = { trigger: '@', name: 'reference', showGroupTitle: false, async candidates(session: ClientSessionContext, { query, quoted, drilled, signal }) { - const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( - result => result.ok ? result.value : [], - () => [], - ) + const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal) + .then(result => result.ok ? result.value : []) const sessionLookup = quoted === true ? Promise.resolve([] as SessionReferenceMentionCandidate[]) - : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( - result => result.ok ? result.value : [], - () => [], - ) + : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal) + .then(result => result.ok ? result.value : []) const [fileItems, sessionItems] = await Promise.all([fileLookup, sessionLookup]) if (signal.aborted) return [] // The header already names the directory being listed; rows repeat it only // when there is no header to carry it. const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined const now = Date.now() - const home = connection.generation.getSnapshot()?.host.home + const home = ctx.remote.$host.home const listed = sessions.list.getSnapshot().byId return [ ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 4565e170db..c2b729cd69 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -7,6 +7,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { CandidateRequest, ClientSessionContext, InputTriggerCandidate, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -84,6 +85,8 @@ async function bench( }, }) class RemoteService extends Service { + readonly $host = { home: HOME, isLoopback: true } + constructor(serviceCtx: Context) { super(serviceCtx, 'remote') } @@ -92,7 +95,6 @@ async function bench( ctx.provide('remote.fileReferences', { list: files }) ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) ctx.provide('locale', new LocaleRuntime(ctx)) - 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() @@ -103,7 +105,7 @@ async function bench( describe('apply', () => { it('declares its services and releases the @ reference registration on disposal', async () => { expect(inject).toEqual([ - 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', + 'inputTriggers', 'locale', 'sessions', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', ]) const { fiber } = await bench() @@ -116,6 +118,8 @@ describe('apply', () => { }, }) class RemoteService extends Service { + readonly $host = { home: undefined, isLoopback: false } + constructor(serviceCtx: Context) { super(serviceCtx, 'remote') } @@ -124,7 +128,6 @@ 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', { generation: { getSnapshot: () => undefined } }) ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } }) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() @@ -217,7 +220,10 @@ describe('candidates', () => { ok: true as const, value: [{ path: 'README.md', kind: 'file' as const }], }) - .mockRejectedValueOnce(new Error('file scan failed')) + .mockResolvedValueOnce({ + ok: false as const, + error: new RemoteError('gateway/internal', 'file scan failed', {}), + }) const sessions = vi.fn(() => Promise.resolve({ ok: true as const, value: [{ @@ -267,18 +273,16 @@ describe('candidates', () => { ok: true as const, value: [{ path: 'bad\nname', kind: 'file' as const }], })) - const sessions = vi.fn() - .mockRejectedValueOnce(new Error('session lookup failed')) - .mockResolvedValueOnce({ - ok: false as const, - error: { code: 'internal', message: 'session lookup failed', details: {} }, - }) + const sessions = vi.fn(() => Promise.resolve({ + ok: false as const, + error: new RemoteError('gateway/internal', 'session lookup failed', {}), + })) const { source } = await bench(files, sessions) await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) files.mockResolvedValueOnce({ ok: false as const, - error: { code: 'internal', message: 'file lookup failed', details: {} }, + error: new RemoteError('gateway/internal', 'file lookup failed', {}), } as never) await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) }) diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 3f4de05dd4..2a21623ef0 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -34,7 +34,6 @@ "inject": [ "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-sidebar" ], @@ -52,7 +51,6 @@ }, "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", @@ -64,7 +62,6 @@ }, "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:^", diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index abdf2829b3..0f12408793 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -8,7 +8,8 @@ * Export discipline: packages/client/AGENTS.md. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: pulls the ctx.remote merge and its fixed Host facts. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: the settings slot declarations plus the ctx.settingsScope Context // merge. Cross-plugin collaboration goes through the service, never a value @@ -55,7 +56,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registrations depend on their slots through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'] +export const inject = ['slots', 'locale', 'remote', 'remote.settings', 'settingsScope'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -69,10 +70,9 @@ export function apply(ctx: ClientContext): void { // seat, and the nav label is a thunk the owner resolves per render — no // locale/change re-registration wiring. const t = ctx.locale.bind(NS) - const connection = ctx.get('connection') as ConnectionHandle // The shared SettingsScope mirror updates after document commits and reconnects. - const documentController = connection.isLoopback - ? new SettingsDocumentStore(ctx.remote, ctx.settingsScope.describe()) + const documentController = ctx.remote.$host.isLoopback + ? new SettingsDocumentStore(ctx, ctx.settingsScope.describe()) : undefined const documentInjected = documentController === undefined ? undefined diff --git a/packages/client/ui-settings-general/src/client/settings-document-store.ts b/packages/client/ui-settings-general/src/client/settings-document-store.ts index 4e7fec2ecd..1a5f45d7ee 100644 --- a/packages/client/ui-settings-general/src/client/settings-document-store.ts +++ b/packages/client/ui-settings-general/src/client/settings-document-store.ts @@ -1,6 +1,8 @@ /** State owner for the optional local settings-document action. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -14,10 +16,6 @@ export interface SettingsDocumentState { error: string | null } -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - /** Derives local-document availability from the shared mirror and invokes the pathless Host-owned open operation. */ export class SettingsDocumentStore { /** uSES-safe state source shared by the registered header action. */ @@ -28,11 +26,12 @@ export class SettingsDocumentStore { private following: (() => void) | undefined /** - * @param api - loopback settings wire face that opens the provider document. + * @param ctx - the plugin's context, whose loopback `remote.settings` + * namespace opens the provider document. * @param describeFace - the shared mirror's describe face (`hasDocument` source). */ constructor( - private readonly remote: Pick, + private readonly ctx: ClientContext, private readonly describeFace: SettingsDescribeFace, ) {} @@ -63,10 +62,11 @@ export class SettingsDocumentStore { state.error = null }) try { - const result = await this.remote.settings.openSettingsDocument() - if (!result.ok) throw new Error(result.error.message) - } catch (error) { - this.store.update((state) => { state.error = messageOf(error) }) + const result = await this.ctx.remote.settings.openSettingsDocument() + if (!result.ok) { + const { message } = result.error + this.store.update((state) => { state.error = message }) + } } finally { this.store.update((state) => { state.opening = false }) } diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 86ec40db91..5b9c4d04ed 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -42,12 +42,11 @@ async function bench(isLoopback = true) { const settingsOpenDocument = vi.fn(() => Promise.resolve({ ok: true as const, value: { opened: true as const }, })) - ctx.provide('connection', { - isLoopback, - } as never) - new TestRemote(ctx, { + const remote = new TestRemote(ctx, { settings: { describe: settingsDescribe, openSettingsDocument: settingsOpenDocument }, }) + // The fixed Host facts the shell reads its loopback-only action from. + remote.$host = { home: undefined, isLoopback } await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } } @@ -76,7 +75,7 @@ function generalEntry(slots: SlotRegistry) { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.settings', 'settingsScope']) }) it('fills all five seats for declarations before or after apply', async () => { diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 19464e6778..d106f0211e 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' @@ -10,10 +10,10 @@ import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' -/** Store over a real mirror derived from the same fake wire. */ -function derivedDocumentStore(api: object) { - const wire = api as never - return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire)) +/** Store over a real mirror derived from the same scripted context. */ +function derivedDocumentStore(remote: object) { + const ctx = { remote } as never + return new SettingsDocumentStore(ctx, new SettingsDescribeMirror(ctx)) } import { en } from '../src/client/locales.ts' @@ -97,9 +97,9 @@ describe('SettingsDocumentAction', () => { const describe = vi.fn() .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }) .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }) - const wire = { settings: { describe, openSettingsDocument: vi.fn() } } as never - const mirror = new SettingsDescribeMirror(wire) - const controller = new SettingsDocumentStore(wire, mirror) + const ctx = { remote: { settings: { describe, openSettingsDocument: vi.fn() } } } as never + const mirror = new SettingsDescribeMirror(ctx) + const controller = new SettingsDocumentStore(ctx, mirror) const first = render( { })), openSettingsDocument: vi.fn(() => Promise.resolve({ ok: false as const, - error: { code: 'internal' as const, message: 'xdg-open missing', details: {} }, + error: new RemoteError('gateway/internal', 'xdg-open missing', {}), })), }, }) diff --git a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts index b166e8b452..6bcc3d662e 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts +++ b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it, vi } from 'vitest' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' -/** Store over a real mirror derived from the same fake wire. */ -function derivedDocumentStore(api: object) { - const wire = api as never - return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire)) +/** Store over a real mirror derived from the same scripted context. */ +function derivedDocumentStore(remote: object) { + const ctx = { remote } as never + return new SettingsDocumentStore(ctx, new SettingsDescribeMirror(ctx)) } function response(hasDocument = false) { @@ -18,7 +19,7 @@ function opened(): RemoteResult<{ opened: true }> { } function describeFailed(message: string) { - return { ok: false as const, error: { code: 'internal', message, details: {} } } + return { ok: false as const, error: new RemoteError('gateway/internal', message, {}) } } describe('SettingsDocumentStore', () => { @@ -69,42 +70,28 @@ describe('SettingsDocumentStore', () => { const first = controller.open() const second = controller.open() expect(openDocument).toHaveBeenCalledOnce() - resolveOpen({ ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }) + resolveOpen({ ok: false, error: new RemoteError('gateway/internal', 'no default editor', {}) }) await Promise.all([first, second]) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', opening: false, error: 'no default editor', }) }) - it('reports non-Error native failures and recovers availability via a mirror refresh', async () => { - let rejectOpen!: (reason?: unknown) => void - const controller = derivedDocumentStore({ - settings: { - describe: vi.fn(() => Promise.resolve(response(true))), - openSettingsDocument: () => new Promise((_, reject) => { rejectOpen = reject }), - }, - }) - await controller.load() - expect(controller.store.getSnapshot().status).toBe('ready') - const opening = controller.open() - rejectOpen('native unavailable') - await opening - expect(controller.store.getSnapshot()).toMatchObject({ - status: 'ready', opening: false, error: 'native unavailable', - }) - + it('recovers availability via a mirror refresh after a failed first read', async () => { // A first read that failed leaves the action unavailable with the miss // recorded; the mirror's next refresh (a commit or reconnect) recovers it. - const wire = { - settings: { - describe: vi.fn() - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce(response(true)), - openSettingsDocument: vi.fn(), + const ctx = { + remote: { + settings: { + describe: vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(response(true)), + openSettingsDocument: vi.fn(), + }, }, } as never - const mirror = new SettingsDescribeMirror(wire) - const caught = new SettingsDocumentStore(wire, mirror) + const mirror = new SettingsDescribeMirror(ctx) + const caught = new SettingsDocumentStore(ctx, mirror) await caught.load() expect(caught.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) await mirror.load() diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 4bd9263dec..9dbb7ebafe 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -2,6 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '../src/client/index.ts' import type { SettingsRootInjected } from '../src/client/shell-contract.ts' @@ -18,13 +19,16 @@ async function bench() { getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }), subscribe: () => () => {}, } as never) - ctx.provide('connection', { api: {}, isLoopback: false } as never) // The shell mounts ui-settings, which injects `remote.settings`; without the // namespace provided its fiber parks and no slot is ever declared. const settings = { - describe: async () => ({ ok: false, error: { code: 'internal', message: 'no settings', details: {} } }), + describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }), } - ctx.provide('remote', { $on: () => () => {}, settings } as never) + ctx.provide('remote', { + $on: () => () => {}, + $host: { home: undefined, isLoopback: false }, + settings, + } as never) ctx.provide('remote.settings', settings as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry } @@ -55,7 +59,7 @@ const CHILD_SPECS = { describe('ui-settings apply', () => { it('declares only the slot registry (a pure composition face, no locale)', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.settings', 'settingsScope', ]) }) diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 024173af2b..de1c20b8bf 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -23,14 +23,14 @@ 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' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' -import { deriveKeyRef, messageOf } from './store.ts' -import type { ModelsWire } from './store.ts' +import { deriveKeyRef } from './store.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 - /** Wire faces for the write and for interrogating the endpoint. */ - api: ModelsWire + /** The page plugin's context, whose Remote namespaces carry the write and the endpoint interrogation. */ + ctx: ClientContext /** 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, api, t } = props + const { taken, protocols, ctx, 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,12 +147,16 @@ 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 api.settings.mutate( + const response = await ctx.remote.settings.mutate( NS, [{ op: 'set', path: ['providers', route], value: profile as JsonValue }], openedAt, ) - if (!response.ok) return response.error.message + if (!response.ok) { + return response.error.code === 'settings/conflict' + ? t('conflict') + : response.error.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 // just superseded, so the Host would answer `settings-conflict` and the @@ -160,7 +164,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { setCommitted(true) } if (storesKey) { - const stored = await api.credentials.set(keyRef, keyValue) + const stored = await ctx.remote.credentials.set(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 @@ -178,10 +182,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { return } props.onClose(true) - } catch (error) { - // A transport failure rejects rather than answering; without this the - // card would stay busy with nothing shown. - setFailure(messageOf(error)) } finally { setBusy(false) } @@ -274,7 +274,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { ...keyValue.length === 0 ? {} : { apiKey: keyValue }, }} probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure} - api={api} + ctx={ctx} t={t} disabled={profileDisabled} /> diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index c27f5379a8..546f89e4a0 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -8,9 +8,10 @@ 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, ModelsWire } from './store.ts' +import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' import { onboardingReadiness } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor } from './ProviderEditor.tsx' @@ -26,8 +27,8 @@ export interface DeepSeekOnboardingInjected { } /** Shared Models-page join controller. */ controller: ModelsSettingsStore - /** Existing wire face reused by the Models credential editor. */ - api: ModelsWire + /** The plugin context the reused Models credential editor writes through. */ + ctx: ClientContext /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Feature copy. */ @@ -50,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, api, schema, t } = props + const { complete, controller, useModels, ctx, schema, t } = props const state = useModels(snapshot => snapshot) const readiness = onboardingReadiness(state) @@ -105,7 +106,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): namespace={namespace} schema={schema} settingsPath={row.entry.settingsPath} - api={api} + ctx={ctx} t={t} readOnly={false} hideTitle diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index a1be9f9085..69714486c2 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -16,11 +16,11 @@ 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 { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' -import { messageOf, type ModelsWire } from './store.ts' 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 - /** Wire face the fetch action calls. */ - api: Pick + /** The page plugin's context, whose `remote.llm` namespace answers the fetch action. */ + ctx: ClientContext /** 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, api, t, disabled } = props + const { models, onChange, probe, ctx, t, disabled } = props const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) const [candidates, setCandidates] = useState(undefined) @@ -229,7 +229,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { setBusy(true) setFailure(undefined) try { - const response = await api.llm.discoverModels(probe.settingsNs, { + const response = await ctx.remote.llm.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 }, @@ -249,10 +249,6 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const known = new Set(models.map(model => textOf(model, 'id'))) setCandidates(found) setPicked(new Set(found.filter(model => !known.has(model.id)).map(model => model.id))) - } catch (error) { - // The transport rejected rather than answering; without this the button - // would stay busy with nothing shown. - setFailure(messageOf(error)) } finally { setBusy(false) } diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 7b884ac906..4611067f3e 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -14,13 +14,14 @@ 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). import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' -import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts' -import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts' +import { deriveKeyRef, protocolChoices, providerUsable } from './store.ts' +import type { ModelsSettingsStore, ProviderRow } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -34,8 +35,8 @@ export interface ModelsSectionInjected { /** Page snapshot bound by the UI renderer as useSnapshot. */ snapshot: ModelsSettingsStore['store'] } - /** Wire faces the editor writes through. */ - api: ModelsWire + /** The page plugin's context, whose Remote namespaces the editors write through. */ + ctx: ClientContext /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Section copy. */ @@ -80,7 +81,7 @@ interface EditorTarget extends ProviderIdentity { /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, - 'namespace' | 'schema' | 'api' | 't' | 'readOnly' | 'onClose' + 'namespace' | 'schema' | 'ctx' | 't' | 'readOnly' | 'onClose' > { target: EditorTarget } @@ -104,32 +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 api - settings and credential wire faces. + * @param ctx - the page plugin's context, carrying the settings and credential Remote namespaces. * @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( - api: Pick, + ctx: ClientContext, controller: ModelsSettingsStore, target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise { - try { - if (target.credentialRef !== undefined) { - const credential = await api.credentials.unset(target.credentialRef) - if (!credential.ok) return credential.error.message - } - const response = await api.settings.mutate( - target.settingsNs, - [{ op: 'unset', path: [...target.settingsPath] }], - undefined, - ) - if (!response.ok) return response.error.message - } catch (error) { - // The transport rejected rather than answering; the caller must be able - // to retry the idempotent operation instead of the row silently staying. - return messageOf(error) + if (target.credentialRef !== undefined) { + const credential = await ctx.remote.credentials.unset(target.credentialRef) + if (!credential.ok) return credential.error.message } + const response = await ctx.remote.settings.mutate( + target.settingsNs, + [{ op: 'unset', path: [...target.settingsPath] }], + undefined, + ) + if (!response.ok) return response.error.message await controller.load() return undefined } @@ -198,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, api, schema, t, renderSlot } = props + const { controller, useSnapshot, ctx, schema, t, renderSlot } = props if ( - controller === undefined || useSnapshot === undefined || api === undefined + controller === undefined || useSnapshot === undefined || ctx === undefined || schema === undefined || t === undefined ) return null - return + return } function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode { - const { controller, api, schema, t } = injected + const { controller, ctx, schema, t } = injected const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) @@ -255,7 +250,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS if (deleteTarget === undefined || deleting) return setDeleting(true) setDeleteFailure(undefined) - void removeProviderProfile(api, controller, deleteTarget) + void removeProviderProfile(ctx, controller, deleteTarget) .then((failure) => { if (failure !== undefined) { setDeleteFailure(failure) @@ -336,7 +331,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - api, + ctx, t, readOnly: !state.writable, onClose: (changed) => { closeSetup(changed, target) }, @@ -431,7 +426,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - api, + ctx, t, readOnly: !state.writable, onClose: (changed) => { closeEditor(changed, target) }, @@ -471,7 +466,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS namespace={addNamespace} schema={schema} settingsPath={addTarget.settingsPath} - api={api} + ctx={ctx} t={t} readOnly={!state.writable} onClose={(changed) => { closeEditor(changed, addTarget) }} @@ -493,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} - api={api} + ctx={ctx} t={t} readOnly={!state.writable} onClose={(changed) => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 675c852125..3c4cea5aab 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -23,6 +23,7 @@ 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' @@ -32,8 +33,7 @@ import { import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' -import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' -import type { ModelsWire } from './store.ts' +import { deriveKeyRef, protocolChoices } from './store.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[] - /** Wire faces for writes and for interrogating a provider endpoint. */ - api: ModelsWire + /** The page plugin's context, whose Remote namespaces carry the writes and the endpoint interrogation. */ + ctx: ClientContext /** 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, api, t } = props + const { namespace, schema, settingsPath, ctx, t } = props const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') const [keyState, setKeyState] = useState(undefined) @@ -186,19 +186,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { useEffect(() => { let stale = false setKeyState(undefined) - // The key state is a placeholder hint, not a precondition for editing: - // neither a business rejection nor a transport failure may reach the - // browser as an unhandled rejection, so the card simply renders without - // the "already configured" hint. - void api.credentials.describe([keyRef]).then( - (response) => { - if (stale || !response.ok) return - setKeyState(response.value[keyRef]) - }, - () => 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]) + }) return () => { stale = true } - }, [api.credentials, keyRef]) + }, [ctx, keyRef]) const stringAt = (source: unknown, key: string): string | undefined => { const value = schema.getPath(source, [key]) @@ -282,9 +277,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ? [{ op: 'set', path: [...settingsPath], value: {} }] : pathOps(settingsPath, committedOriginal, next) if (ops.length > 0) { - const response = await api.settings.mutate(ns, ops, expectedRevision) + const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision) if (!response.ok) { - return response.error.code === 'settings-conflict' + return response.error.code === 'settings/conflict' ? t('conflict') : response.error.message } @@ -293,7 +288,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { setDraft(next) } if (keyValue.length > 0) { - const stored = await api.credentials.set(keyRef, keyValue) + const stored = await ctx.remote.credentials.set(keyRef, keyValue) if (!stored.ok) return stored.error.message } setKeyDraft('') @@ -310,11 +305,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { return } props.onClose(true) - } catch (error) { - // A transport failure (disconnect, a request the host refuses) rejects - // rather than answering; without this the card would stay busy forever - // with no error shown. - setFailure(messageOf(error)) } finally { setBusy(false) } @@ -474,7 +464,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} /> ) - : } + : } } diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index c397d795c9..8cb361ead6 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -23,7 +23,6 @@ import { WelcomeNotice } from './WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts' import { ModelsSettingsStore } from './store.ts' -import type { ModelsWire } from './store.ts' import { createSettingsSchemaOperations } from './schema-operations.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' @@ -42,7 +41,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'settings.models' export type { - ModelsCredentials, ModelsLlm, ModelsSettingsState, ModelsWire, ProviderDirectoryEntry, ProviderRow, + ModelsSettingsState, ProviderDirectoryEntry, ProviderRow, } from './store.ts' /** @@ -75,27 +74,21 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') const schema = createSettingsSchemaOperations(ctx.settingsSchema) - // Every configuration operation rides its owning Remote namespace. - const wire: ModelsWire = { - credentials: ctx.remote.credentials, - llm: ctx.remote.llm, - settings: ctx.remote.settings, - } - const controller = new ModelsSettingsStore(wire, schema, ctx.settingsScope.describe()) + 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. const t = ctx.locale.bind(NS) as ModelsSectionInjected['t'] const injected = (): ModelsSectionInjected => ({ controller, hooks: { snapshot: controller.store }, - api: wire, + ctx, schema, t, }) const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({ controller, hooks: { models: controller.store }, - api: wire, + ctx, schema, t, }) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index fe98870321..fd8eeaf9f9 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -7,12 +7,13 @@ * re-renders from the next describe, pushed or refetched. */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { - ClientRemote, CredentialInfo, LlmConfigurableProvider, LlmProviderInfo, SettingsNamespaceView, + CredentialInfo, LlmConfigurableProvider, LlmProviderInfo, SettingsNamespaceView, } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsDescribeFace, SettingsRemote } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SettingsSchemaOperations } from './schema-operations.ts' /** @@ -21,15 +22,6 @@ import type { SettingsSchemaOperations } from './schema-operations.ts' */ const PROBE_ROUTE = '\u0000probe' -/** The credentials Remote methods the Models page reads and writes through. */ -export type ModelsCredentials = Pick - -/** LLM Remote methods used by the Models page. */ -export type ModelsLlm = Pick< - ClientRemote['llm'], - 'discoverModels' | 'listConfigurableProviders' | 'listProviders' -> - /** One provider row after joining the configurable directory with live routes. */ export interface ProviderDirectoryEntry { readonly provider: string @@ -73,18 +65,6 @@ export function joinProviderDirectory( return rows } -/** - * Every Remote wire face the Models page reaches. - */ -export interface ModelsWire { - /** The settings Remote namespace: the redacted read and the profile writes. */ - settings: SettingsRemote - /** Credential state and writes for the references provider profiles name. */ - credentials: ModelsCredentials - /** Provider directory reads and draft endpoint discovery. */ - llm: ModelsLlm -} - /** One provider row the page renders. */ export interface ProviderRow { /** The directory entry (route id, display name, settings address, live state). */ @@ -121,17 +101,6 @@ export interface ModelsSettingsState { namespaces: ReadonlyMap } -/** - * Human text for a rejected wire call. A transport failure rejects with an - * Error; a host or a runtime can reject with anything, and the page still has - * to say something. - * @param error - the rejection value. - * @returns the message to show. - */ -export function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - /** * Derive the conventional credential reference for a provider route: the v1 * page never asks for an environment-variable name, so a typed key stores @@ -187,11 +156,13 @@ export class ModelsSettingsStore { private generation = 0 /** - * @param api - the page's credentials Remote and LLM wire faces. + * @param ctx - the page plugin's context, whose `remote.llm` and + * `remote.credentials` namespaces carry the directory and credential reads. + * @param schema - settings-owned schema and immutable path operations. * @param describeFace - the shared mirror's describe face (namespace views and writability). */ constructor( - private readonly api: Pick, + private readonly ctx: ClientContext, private readonly schema: SettingsSchemaOperations, private readonly describeFace: SettingsDescribeFace, ) {} @@ -207,32 +178,21 @@ export class ModelsSettingsStore { async load(): Promise { const generation = ++this.generation this.store.update((s) => { s.status = 'loading'; s.error = null }) - let providers: ProviderDirectoryEntry[] - let writable: boolean - let views: readonly SettingsNamespaceView[] - try { - const [registered, declared] = await Promise.all([ - this.api.llm.listProviders(), - this.api.llm.listConfigurableProviders(), - this.describeFace.ensure(), - ]) - if (!registered.ok) throw new Error(registered.error.message) - if (!declared.ok) throw new Error(declared.error.message) - const mirrored = this.describeFace.getSnapshot() - if (mirrored.view === undefined) { - throw new Error(mirrored.error ?? 'settings are unavailable in this browser') - } - providers = joinProviderDirectory(registered.value, declared.value) - writable = mirrored.view.writable - views = mirrored.view.namespaces - } catch (error) { - if (generation !== this.generation) return - this.store.update((s) => { - s.status = 'error' - s.error = error instanceof Error ? error.message : String(error) - }) + const [registered, declared] = await Promise.all([ + this.ctx.remote.llm.listProviders(), + this.ctx.remote.llm.listConfigurableProviders(), + this.describeFace.ensure(), + ]) + if (!registered.ok) { this.failLoad(generation, registered.error.message); return } + if (!declared.ok) { this.failLoad(generation, declared.error.message); return } + const mirrored = this.describeFace.getSnapshot() + if (mirrored.view === undefined) { + this.failLoad(generation, mirrored.error ?? 'settings are unavailable in this browser') return } + const providers = joinProviderDirectory(registered.value, declared.value) + const writable = mirrored.view.writable + const views: readonly SettingsNamespaceView[] = mirrored.view.namespaces const namespaces = new Map(views.map(view => [view.ns, view])) const rows: ProviderRow[] = providers.map((entry) => { const namespace = namespaces.get(entry.settingsNs) @@ -254,16 +214,12 @@ export class ModelsSettingsStore { let credentials: Record = {} let credentialError: string | null = null if (refs.length > 0) { - try { - const response = await this.api.credentials.describe(refs) - // Credential state is an enrichment for the Models page: neither a - // business rejection nor a transport failure fails the load. The - // onboarding projection below retains the failure distinction. - if (response.ok) credentials = response.value - else credentialError = response.error.message - } catch (error) { - credentialError = messageOf(error) - } + const response = await this.ctx.remote.credentials.describe(refs) + // Credential state is an enrichment for the Models page: a failure + // degrades the badge instead of failing the load. The onboarding + // projection below retains the failure distinction. + if (response.ok) credentials = response.value + else credentialError = response.error.message } if (generation !== this.generation) return this.store.update((s) => { @@ -283,6 +239,15 @@ export class ModelsSettingsStore { s.namespaces = namespaces }) } + + /** Publish one load's failure text, unless a newer load already took over. */ + private failLoad(generation: number, message: string): void { + if (generation !== this.generation) return + this.store.update((s) => { + s.status = 'error' + s.error = message + }) + } } /** diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 4513bde5a2..1d42d47682 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -41,7 +41,8 @@ async function bench(isLoopback = true, settings?: object, services: object = {} // ui-settings apply also provides the settingsSchema service. settings: settings ?? scriptedSettingsRemote().settings, }) - ctx.provide('connection', { api: services, isLoopback } as never) + // The fixed Host facts the settings provider reads its persistence from. + remote.$host = { home: undefined, isLoopback } await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, remote } } @@ -84,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.api).toBeDefined() + expect(injected.ctx).toBeDefined() const onboarding = before.slots.entries('settings.onboarding') expect(onboarding).toHaveLength(2) expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({ @@ -98,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.api).toBeDefined() + expect(deepSeekInjected.ctx).toBeDefined() const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index a22f787a1b..361e080e32 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -3,8 +3,10 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { + CredentialInfo, JsonValue, RemoteResult, SettingsNamespaceView, +} from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' @@ -137,8 +139,19 @@ function wireNamespaces(): SettingsNamespaceView[] { function remoteOk(value: T) { return { ok: true as const, value } } -function remoteFail(message: string, code = 'credential-rejected') { - return { ok: false as const, error: { code, message, details: {} } } +/** The codes this page's scripted Host answers refuse with. */ +type RefusalCode = 'credential/rejected' | 'gateway/internal' | 'settings/conflict' | 'settings/rejected' + +/** One refusal per code, each carrying the details its own code declares. */ +const REFUSALS: { [Code in RefusalCode]: (message: string) => RemoteError } = { + 'credential/rejected': message => new RemoteError('credential/rejected', message, { ref: 'DEEPSEEK_API_KEY' }), + 'gateway/internal': message => new RemoteError('gateway/internal', message, {}), + 'settings/conflict': message => + new RemoteError('settings/conflict', message, { ns: 'llm-pi-ai', expected: 4, actual: 5 }), + 'settings/rejected': message => new RemoteError('settings/rejected', message, { ns: 'llm-pi-ai' }), +} +function remoteFail(message: string, code: RefusalCode = 'credential/rejected') { + return { ok: false as const, error: REFUSALS[code](message) } } function scriptedFace(overrides: { @@ -174,13 +187,16 @@ function scriptedFace(overrides: { mutate, }, credentials: { - describe: vi.fn((refs: string[]) => Promise.resolve(remoteOk( - Object.fromEntries(refs.map(ref => [ref, { - configured: ref === 'OPENAI_API_KEY', - ...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {}, - writable: true, - }])), - ))), + // Typed as the Remote answer rather than the success branch alone: a + // case that scripts a refusal replaces this mock. + describe: vi.fn((refs: string[]): Promise>> => + Promise.resolve(remoteOk( + Object.fromEntries(refs.map(ref => [ref, { + configured: ref === 'OPENAI_API_KEY', + ...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {}, + writable: true, + }])), + ))), set, unset, }, @@ -188,7 +204,21 @@ function scriptedFace(overrides: { return { face, update, mutate, set, unset } } -type WireFace = ConstructorParameters[0] +type PageContext = ConstructorParameters[0] + +/** + * The page plugin's context, scripted down to the namespaces the page reaches. + * One context per face, as in production: an editor effect keyed by the context + * would otherwise re-probe on every render. + */ +const contexts = new WeakMap() +function ctxWith(face: object): PageContext { + const existing = contexts.get(face) + if (existing !== undefined) return existing + const ctx = { remote: face } as unknown as PageContext + contexts.set(face, ctx) + return ctx +} /** One recorded child-slot dispatch: seat name, owner share, kind options. */ type RenderSlotCall = [name: string, owner: Record, opts?: { entryKey?: string }] @@ -214,20 +244,21 @@ function cardSeatCalls( async function mountFace(scripted: ReturnType) { const { face, update, mutate, set, unset } = scripted - const mirror = new SettingsDescribeMirror(face as never) - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, mirror) + const ctx = ctxWith(face) + const mirror = new SettingsDescribeMirror(ctx) + const controller = new ModelsSettingsStore(ctx, settingsSchema, mirror) await controller.load() const renderSlot = stubRenderSlot() const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - api: face as never, + ctx, schema: settingsSchema, t, renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'], } const view = render() - return { view, face, update, mutate, set, unset, controller, mirror, renderSlot } + return { view, ctx, face, update, mutate, set, unset, controller, mirror, renderSlot } } async function mountSection(overrides: Parameters[0] = {}) { @@ -351,12 +382,12 @@ describe('ModelsSection', () => { face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])), ))) - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) await controller.load() render( null} @@ -375,13 +406,13 @@ describe('ModelsSection', () => { face.credentials.describe.mockImplementation((refs: string[]) => Promise.resolve(remoteOk( Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), ))) - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) await controller.load() cleanup() render( null} @@ -463,7 +494,7 @@ describe('ModelsSection', () => { namespace={wireNamespaces()[0]!} schema={settingsSchema} settingsPath={[]} - api={face as never} + ctx={ctxWith(face)} t={t} readOnly={false} credentialOnly @@ -716,7 +747,7 @@ describe('ModelsSection', () => { namespace={overridden} schema={settingsSchema} settingsPath={[]} - api={face as never} + ctx={ctxWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -946,7 +977,7 @@ describe('ModelsSection', () => { namespace={bare} schema={settingsSchema} settingsPath={[]} - api={face as never} + ctx={ctxWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -1089,7 +1120,7 @@ describe('ModelsSection', () => { it('surfaces a rejected settings write and never stores the key after it', async () => { const { set } = await mountSection({ - mutate: vi.fn(() => Promise.resolve(remoteFail('llm-pi-ai: unknown pi-ai provider "bogus"', 'settings-rejected'))), + mutate: vi.fn(() => Promise.resolve(remoteFail('llm-pi-ai: unknown pi-ai provider "bogus"', 'settings/rejected'))), }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) @@ -1099,38 +1130,28 @@ describe('ModelsSection', () => { expect(set).not.toHaveBeenCalled() }) - it('renders the card without the stored-key hint when the credential probe rejects', async () => { - // The probe is a placeholder hint, not a precondition: an escaping - // rejection would surface in the browser as an unhandled rejection. + it('renders the card without the stored-key hint when the credential probe is refused', async () => { const { face } = scriptedFace() - face.credentials.describe = vi.fn(() => Promise.reject(new Error('connection lost'))) - const unhandled = vi.fn() - process.on('unhandledRejection', unhandled) - try { - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) - await controller.load() - render( null} - />) - const key = await screen.findByLabelText(en.keyInput) - expect(key.placeholder).toBe(en.keyPlaceholder) - await new Promise(resolve => setTimeout(resolve, 10)) - expect(unhandled).not.toHaveBeenCalled() - } finally { - process.off('unhandledRejection', unhandled) - } + face.credentials.describe = vi.fn(() => Promise.resolve(remoteFail('no credential provider'))) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) + await controller.load() + render( null} + />) + const key = await screen.findByLabelText(en.keyInput) + expect(key.placeholder).toBe(en.keyPlaceholder) }) it('tells the user to reopen when another writer moved the namespace first', async () => { // The stale-draft overwrite: two tabs open the same card, the other saves, // and this one must be refused rather than replay its opening snapshot. const { set } = await mountDeepSeekCard({ - mutate: vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings-conflict'))), + mutate: vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings/conflict'))), }) fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://mine' } }) @@ -1139,15 +1160,14 @@ describe('ModelsSection', () => { expect(set).not.toHaveBeenCalled() }) - it('keeps the card usable when the write rejects instead of answering', async () => { - // A transport failure (disconnect, or the 403 a non-loopback browser now - // gets on the whole configuration plane) rejects rather than returning a - // failed envelope: without a catch the card would stay busy forever. - await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) }) + it('keeps the card usable after a refused write', async () => { + await mountDeepSeekCard({ + mutate: vi.fn(() => Promise.resolve(remoteFail('the host refused', 'settings/rejected'))), + }) fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://next' } }) fireEvent.click(screen.getByText(en.apply)) - await screen.findByText('connection lost') + await screen.findByText('the host refused') // Not stuck in `applying…`: the finally cleared busy, so Apply is live again. expect(screen.getByText(en.apply)).toBeTruthy() }) @@ -1178,7 +1198,7 @@ describe('ModelsSection', () => { it('keeps a failed credential describe silent and the input usable', async () => { const { face, set } = await mountSection() - face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'internal')) as never) + face.credentials.describe.mockImplementation(() => Promise.resolve(remoteFail('down', 'gateway/internal'))) fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) const editorKey = await screen.findByLabelText(en.keyInput) expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) @@ -1245,14 +1265,14 @@ describe('ModelsSection', () => { it('renders the load failure with a retry control', async () => { const face = scriptedFace() - face.face.llm.listProviders = vi.fn(() => Promise.resolve(remoteFail('directory down', 'internal'))) as never + face.face.llm.listProviders = vi.fn(() => Promise.resolve(remoteFail('directory down', 'gateway/internal'))) as never const controller = new ModelsSettingsStore( - face.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face.face as never)) + ctxWith(face.face), settingsSchema, new SettingsDescribeMirror(ctxWith(face.face))) await controller.load() render( null} @@ -1269,13 +1289,13 @@ describe('ModelsSection', () => { hasDocument: false, namespaces: wireNamespaces(), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) await controller.load() cleanup() render( null} @@ -1333,11 +1353,11 @@ describe('ModelsSection', () => { it('loads on first render of an idle controller', async () => { const { face } = scriptedFace() - const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face as never)) + const controller = new ModelsSettingsStore(ctxWith(face), settingsSchema, new SettingsDescribeMirror(ctxWith(face))) render( null} @@ -1350,7 +1370,7 @@ describe('ModelsSection', () => { // would widen the write for no benefit. const { face, mutate, controller } = await mountSection() await removeProviderProfile( - face as unknown as Parameters[0], + ctxWith(face), controller, { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] }, ) @@ -1363,11 +1383,11 @@ describe('ModelsSection', () => { it('keeps the snapshot untouched and reports the message when a removal write is refused', async () => { const { face, controller } = await mountSection({ - mutate: vi.fn(() => Promise.resolve(remoteFail('read-only', 'settings-rejected'))), + mutate: vi.fn(() => Promise.resolve(remoteFail('read-only', 'settings/rejected'))), }) const before = controller.store.getSnapshot().rows const failure = await removeProviderProfile( - face as unknown as Parameters[0], + ctxWith(face), controller, { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, ) @@ -1377,7 +1397,7 @@ describe('ModelsSection', () => { it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => { const mutate = vi.fn() - .mockResolvedValueOnce(remoteFail('the host refused', 'settings-rejected')) + .mockResolvedValueOnce(remoteFail('the host refused', 'settings/rejected')) .mockResolvedValueOnce(remoteOk(wireNamespaces()[2]!)) const { unset } = await mountSection({ mutate }) fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) @@ -1418,7 +1438,7 @@ describe('ModelsSection', () => { unset: vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))), }) const failure = await removeProviderProfile( - face as unknown as Parameters[0], + ctxWith(face), controller, { settingsNs: 'llm-pi-ai', @@ -1430,17 +1450,6 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() }) - it('reports a transport rejection instead of failing the removal silently', async () => { - const { face, controller } = await mountSection({ - mutate: vi.fn(() => Promise.reject(new Error('connection lost'))), - }) - const failure = await removeProviderProfile( - face as unknown as Parameters[0], - controller, - { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, - ) - expect(failure).toBe('connection lost') - }) }) describe('apiKeyFailure', () => { diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 54b7bdd815..92d57eb776 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -22,7 +22,7 @@ function remoteOk(value: T) { return { ok: true as const, value } } function remoteFail(message: string) { - return { ok: false as const, error: { code: 'internal', message, details: {} } } + return { ok: false as const, error: new RemoteError('gateway/internal', message, {}) } } const DeepSeekConfig = Schema.object({ @@ -66,9 +66,8 @@ function harness(options: { credential?: { source?: string; writable: boolean } describeFailure?: string settingsWritable?: boolean - providersReject?: boolean + providersFailure?: string setFailure?: string - setReject?: string } = {}) { if (document.getElementById('root') === null) { const appRoot = document.createElement('div') @@ -80,7 +79,6 @@ function harness(options: { const apiKeyEnv = options.apiKeyEnv === undefined ? 'DEEPSEEK_API_KEY' : options.apiKeyEnv const mutate = vi.fn(() => Promise.resolve(remoteOk(deepSeekNamespace(apiKeyEnv)))) const set = vi.fn((_ref: string, _value: string) => { - if (options.setReject !== undefined) return Promise.reject(new Error(options.setReject)) if (options.setFailure !== undefined) return Promise.resolve(remoteFail(options.setFailure)) fileConfigured = true return Promise.resolve(remoteOk(undefined)) @@ -88,7 +86,7 @@ function harness(options: { const face = { llm: { listProviders: () => { - if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable')) + if (options.providersFailure !== undefined) return Promise.resolve(remoteFail(options.providersFailure)) return Promise.resolve(remoteOk( options.provider === false || options.providerActive === false ? [] @@ -130,7 +128,9 @@ function harness(options: { set, }, } - const controller = new ModelsSettingsStore(face as never, settingsSchema, new SettingsDescribeMirror(face as never)) + // The page plugin's context, scripted down to the namespaces it reaches. + const ctx = { remote: face } as never + const controller = new ModelsSettingsStore(ctx, settingsSchema, new SettingsDescribeMirror(ctx)) const openSection = vi.fn() const complete = vi.fn() const unusedHook = (() => { throw new Error('unused standard hook') }) as never @@ -143,7 +143,7 @@ function harness(options: { useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), - api: face as never, + ctx, schema: settingsSchema, t: key => en[key], } @@ -200,10 +200,9 @@ describe('DeepSeekOnboardingDialog', () => { expect(h.set).not.toHaveBeenCalled() }) - it('keeps the modal open and reports rejected and failed credential writes', async () => { + it('keeps the modal open and reports a refused credential write', async () => { for (const [options, message] of [ [{ setFailure: 'credential was rejected' }, 'credential was rejected'], - [{ setReject: 'connection lost' }, 'connection lost'], ] as const) { const h = harness(options) const view = render() @@ -235,7 +234,7 @@ describe('DeepSeekOnboardingDialog', () => { harness({ describeFailure: 'credentials service is absent' }), harness({ credential: { writable: false } }), harness({ settingsWritable: false }), - harness({ providersReject: true }), + harness({ providersFailure: 'the provider directory is unavailable' }), harness({ providerActive: false }), harness({ settingsNamespace: false }), harness({ apiKeyEnv: null }), diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 0bf6b59861..96e39a8bfc 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' @@ -41,15 +41,33 @@ const PiAiConfig = Schema.object({ function ok(value: T) { return { ok: true as const, value } } -function fail(message: string, code: string) { - return { ok: false as const, error: { code, message, details: {} } } +/** One draft-interrogation failure per code, each carrying its own details. */ +const DISCOVERY_FAILURES: { + [Code in 'gateway/internal' | 'llm/model-discovery-rejected']: (message: string) => RemoteError +} = { + 'gateway/internal': message => new RemoteError('gateway/internal', message, {}), + 'llm/model-discovery-rejected': message => + new RemoteError('llm/model-discovery-rejected', message, { settingsNs: 'llm-pi-ai' }), +} +function fail(message: string, code: keyof typeof DISCOVERY_FAILURES) { + return { ok: false as const, error: DISCOVERY_FAILURES[code](message) } } /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { return { ok: true as const, value } } -function remoteFail(message: string, code = 'credential-rejected') { - return { ok: false as const, error: { code, message, details: {} } } +/** The codes this page's scripted Host answers refuse with. */ +type RefusalCode = 'credential/rejected' | 'settings/conflict' | 'settings/rejected' + +/** One refusal per code, each carrying the details its own code declares. */ +const REFUSALS: { [Code in RefusalCode]: (message: string) => RemoteError } = { + 'credential/rejected': message => new RemoteError('credential/rejected', message, { ref: 'OPENAI_API_KEY' }), + 'settings/conflict': message => + new RemoteError('settings/conflict', message, { ns: 'llm-pi-ai', expected: 7, actual: 8 }), + 'settings/rejected': message => new RemoteError('settings/rejected', message, { ns: 'llm-pi-ai' }), +} +function remoteFail(message: string, code: RefusalCode = 'credential/rejected') { + return { ok: false as const, error: REFUSALS[code](message) } } function piAiNamespace( @@ -121,7 +139,21 @@ function scriptedFace(options: { return { face, discover, mutate, set, namespace } } -type WireFace = ConstructorParameters[0] +type PageContext = ConstructorParameters[0] + +/** + * The page plugin's context, scripted down to the namespaces the page reaches. + * One context per face, as in production: an editor effect keyed by the context + * would otherwise re-probe on every render. + */ +const contexts = new WeakMap() +function ctxWith(face: object): PageContext { + const existing = contexts.get(face) + if (existing !== undefined) return existing + const ctx = { remote: face } as unknown as PageContext + contexts.set(face, ctx) + return ctx +} /** The settings write one card produced, as the scripted face recorded it. */ interface MutateCall { @@ -152,12 +184,12 @@ function firstMutate(mutate: ReturnType): MutateCall { async function mountSection(options: Parameters[0] = {}) { const scripted = scriptedFace(options) const controller = new ModelsSettingsStore( - scripted.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(scripted.face as never)) + ctxWith(scripted.face), settingsSchema, new SettingsDescribeMirror(ctxWith(scripted.face))) await controller.load() const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - api: scripted.face as never, + ctx: ctxWith(scripted.face), schema: settingsSchema, t, renderSlot: () => null, @@ -505,7 +537,7 @@ describe('endpoint interrogation', () => { it('keeps the rows editable when the provider cannot be interrogated', async () => { const discover = vi.fn(() => Promise.resolve( - fail('https://proxy.example/v1/models answered 401; check the API key', 'model-discovery-failed'), + fail('https://proxy.example/v1/models answered 401; check the API key', 'llm/model-discovery-rejected'), )) await mountSection({ discover }) openEditor('openai') @@ -517,19 +549,12 @@ describe('endpoint interrogation', () => { expect(screen.getByRole('button', { name: en.addModel })).toBeTruthy() }) - it('reports an empty listing and a rejected transport', async () => { + it('reports an empty listing', async () => { const empty = vi.fn(() => Promise.resolve(ok([]))) await mountSection({ discover: empty }) openEditor('openai') fireEvent.click(screen.getByText(en.fetchModels)) await screen.findByText(en.fetchEmpty) - cleanup() - - const rejected = vi.fn(() => Promise.reject(new Error('carrier down'))) - await mountSection({ discover: rejected }) - openEditor('openai') - fireEvent.click(screen.getByText(en.fetchModels)) - await screen.findByText('carrier down') }) it('can be asked for a configured route even with no endpoint', async () => { @@ -551,7 +576,7 @@ describe('endpoint interrogation', () => { const scripted = scriptedFace() render( , ) @@ -671,12 +696,12 @@ describe('provider rows', () => { settingsPath: ['providers', 'openai'], }]))) as never const controller = new ModelsSettingsStore( - scripted.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(scripted.face as never)) + ctxWith(scripted.face), settingsSchema, new SettingsDescribeMirror(ctxWith(scripted.face))) await controller.load() render( null} @@ -700,7 +725,7 @@ describe('hand-declared providers', () => { taken={['openai']} protocols={PROTOCOLS} revision={7} - api={scripted.face as never} + ctx={ctxWith(scripted.face)} t={t} readOnly={false} onClose={onClose} @@ -1125,9 +1150,9 @@ describe('hand-declared providers', () => { expect(buttonNamed(en.create).disabled).toBe(false) }) - it('surfaces a refused write and a rejected transport without closing', async () => { - const refused = vi.fn(() => Promise.resolve(remoteFail('read-only settings', 'settings-rejected'))) - const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: refused }).face } as never }) + 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) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1139,9 +1164,9 @@ describe('hand-declared providers', () => { expect(onClose).not.toHaveBeenCalled() }) - it('surfaces a rejected transport during create', async () => { - const rejecting = vi.fn(() => Promise.reject(new Error('carrier down'))) - const { onClose } = mountCard({ api: { ...scriptedFace({ mutate: rejecting }).face } as never }) + 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) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1149,13 +1174,13 @@ describe('hand-declared providers', () => { fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) fireEvent.click(screen.getByText(en.create)) - await screen.findByText('carrier down') + await screen.findByText(en.conflict) expect(onClose).not.toHaveBeenCalled() }) 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({ api: { ...scriptedFace({ set }).face } as never }) + const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ set }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 5f9aced5bb..7757cf3792 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -1,27 +1,28 @@ /** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */ import { describe, expect, it } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' -import { messageOf, ModelsSettingsStore } from '../src/client/store.ts' +import { ModelsSettingsStore } from '../src/client/store.ts' let nextRpc = 0 function ok(value: T): RpcResponse { return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } } function fail(message: string): RpcResponse { - return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } } + return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'gateway/internal', message, details: {} } } } } -/** Credentials answers over the Remote carrier, which has no envelope. */ +/** Answers over the Remote carrier, which has no envelope. */ type RemoteAnswer = | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: { code: string; message: string; details: object } } + | { readonly ok: false; readonly error: RemoteError } function remoteOk(value: T): RemoteAnswer { return { ok: true, value } } function remoteFail(message: string): RemoteAnswer { - return { ok: false, error: { code: 'internal', message, details: {} } } + return { ok: false, error: new RemoteError('gateway/internal', message, {}) } } const DIRECTORY = [ @@ -102,14 +103,15 @@ function api(overrides: { unset: () => Promise.resolve(remoteOk(undefined)), }, } - const wire = face as never - return { face: wire, mirror: new SettingsDescribeMirror(wire), seenRefs } + // The page plugin's context, scripted down to the namespaces it reaches. + const ctx = { remote: face } as never + return { ctx, face, mirror: new SettingsDescribeMirror(ctx), seenRefs } } describe('ModelsSettingsStore', () => { it('joins rows with configured, removable, and credential state', async () => { - const { face, mirror, seenRefs } = api() - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const { ctx, mirror, seenRefs } = api() + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -138,8 +140,8 @@ describe('ModelsSettingsStore', () => { }) it('degrades the credential badge, not the page, when the credential domain fails', async () => { - const { face, mirror } = api({ describeCredentials: () => Promise.resolve(remoteFail('no provider')) }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const { ctx, mirror } = api({ describeCredentials: () => Promise.resolve(remoteFail('no provider')) }) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -147,34 +149,13 @@ describe('ModelsSettingsStore', () => { expect(state.rows.every(row => row.credential === undefined)).toBe(true) }) - it('settles a credential transport rejection without leaving the store loading', async () => { - const { face, mirror } = api({ - describeCredentials: () => Promise.reject(new Error('credential transport down')), - }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) - await expect(store.load()).resolves.toBeUndefined() - expect(store.store.getSnapshot()).toMatchObject({ - status: 'ready', - credentialError: 'credential transport down', - }) - }) - - it('stringifies a non-Error credential transport rejection', async () => { - const { face, mirror } = api({ - describeCredentials: async () => { throw 'credential transport refusal' }, - }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) - await expect(store.load()).resolves.toBeUndefined() - expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal') - }) - it('surfaces a directory failure and keeps the last good rows', async () => { - const { face, mirror } = api() - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const { ctx, mirror } = api() + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() expect(store.store.getSnapshot().rows).toHaveLength(4) const broken = api({ providers: () => Promise.resolve(fail('directory down')) }) - const failing = new ModelsSettingsStore(broken.face, settingsSchema, broken.mirror) + const failing = new ModelsSettingsStore(broken.ctx, settingsSchema, broken.mirror) await failing.load() expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' }) // The first store's snapshot is untouched by the second's failure. @@ -182,12 +163,12 @@ describe('ModelsSettingsStore', () => { }) it('surfaces a configurable-provider directory failure', async () => { - const { face, mirror } = api() + const { ctx, face, mirror } = api() const llm = (face as unknown as { llm: { listConfigurableProviders: () => Promise> } }).llm llm.listConfigurableProviders = () => Promise.resolve(remoteFail('configuration directory down')) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() @@ -200,7 +181,7 @@ describe('ModelsSettingsStore', () => { let release: (() => void) | undefined const gate = new Promise((resolve) => { release = resolve }) let call = 0 - const { face, mirror } = api({ + const { ctx, mirror } = api({ providers: async () => { call += 1 if (call === 1) { @@ -210,7 +191,7 @@ describe('ModelsSettingsStore', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) const first = store.load() const second = store.load() release?.() @@ -221,7 +202,7 @@ describe('ModelsSettingsStore', () => { describe('edge joins', () => { it('treats a non-object profile as having no credential reference', async () => { - const { face, mirror } = api({ + const { ctx, mirror } = api({ describeSettings: () => Promise.resolve(remoteOk({ writable: true, hasDocument: false, @@ -240,7 +221,7 @@ describe('edge joins', () => { ] as never, })), }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() const state = store.store.getSnapshot() expect(state.rows[0]).toMatchObject({ configured: true, removable: false }) @@ -248,7 +229,7 @@ describe('edge joins', () => { }) it('describes the derived reference for a row whose profile names none', async () => { - const { face, mirror, seenRefs } = api({ + const { ctx, mirror, seenRefs } = api({ describeSettings: () => Promise.resolve(remoteOk({ writable: true, hasDocument: false, @@ -263,7 +244,7 @@ describe('edge joins', () => { Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), )), }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() // The dormant row names no reference, so the join asks about the page's // own derived _API_KEY — what the editor would display for it. @@ -275,18 +256,18 @@ describe('edge joins', () => { }) it('surfaces a settings describe failure', async () => { - const { face, mirror } = api({ describeSettings: () => Promise.resolve(remoteFail('settings down')) }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const { ctx, mirror } = api({ describeSettings: () => Promise.resolve(remoteFail('settings down')) }) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' }) }) it('reports a terminally unavailable settings mirror precisely', async () => { - const { face } = api() + const { ctx } = api() const store = new ModelsSettingsStore( - face, + ctx, settingsSchema, - new SettingsDescribeMirror(face, 'memory'), + new SettingsDescribeMirror(ctx, 'memory'), ) await store.load() expect(store.store.getSnapshot()).toMatchObject({ @@ -297,7 +278,7 @@ describe('edge joins', () => { it('reuses a held settings view after its refresh fails', async () => { let settingsCall = 0 - const { face, mirror } = api({ + const { ctx, mirror } = api({ describeSettings: () => { settingsCall += 1 return Promise.resolve(settingsCall === 1 @@ -305,7 +286,7 @@ describe('edge joins', () => { : remoteFail('settings refresh down')) }, }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) await store.load() await mirror.load() expect(mirror.getSnapshot().error).toBe('settings refresh down') @@ -314,19 +295,11 @@ describe('edge joins', () => { expect(store.store.getSnapshot().rows).toHaveLength(4) }) - it('stringifies a non-Error load failure', async () => { - // The wire can surface non-Error throwables; the store must stringify them. - const { face, mirror } = api({ providers: async () => { throw 'plain refusal' } }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) - await store.load() - expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' }) - }) - it('drops a stale successful response after a newer load finished', async () => { let release: (() => void) | undefined const gate = new Promise((resolve) => { release = resolve }) let call = 0 - const { face, mirror } = api({ + const { ctx, mirror } = api({ providers: async () => { call += 1 if (call === 1) { @@ -336,7 +309,7 @@ describe('edge joins', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face, settingsSchema, mirror) + const store = new ModelsSettingsStore(ctx, settingsSchema, mirror) const first = store.load() const second = store.load() await second @@ -346,13 +319,3 @@ describe('edge joins', () => { expect(store.store.getSnapshot().rows).toHaveLength(4) }) }) - -describe('messageOf', () => { - it('reads an Error message, and stringifies anything else a rejection may carry', () => { - // The wire layer rejects with an Error, but a host or a runtime can reject - // with any value, and the page still has to render something. - expect(messageOf(new Error('connection lost'))).toBe('connection lost') - expect(messageOf('the host refused')).toBe('the host refused') - expect(messageOf(undefined)).toBe('undefined') - }) -}) diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 1a48af9d1c..efa8c01665 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { Context } from '@deepseek-ai/cordis' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -70,9 +70,10 @@ function mount( mutate, }, } - const mirror = new SettingsDescribeMirror(api as never) + const ctx = { remote: api } as never + const mirror = new SettingsDescribeMirror(ctx) const scope = new SettingsScopeController( - api as never, + ctx, { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, mirror, 'host', @@ -153,15 +154,8 @@ describe('WelcomeNotice', () => { fireEvent.click(action) expect(action.disabled).toBe(true) resolveWrite({ - rpcId: 'welcome-refused' as never, - result: { - ok: false, - error: { - code: 'settings-rejected', - message: 'read only', - details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE }, - }, - }, + ok: false, + error: new RemoteError('settings/rejected', 'read only', { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE }), }) expect((await screen.findByRole('alert')).textContent).toBe(zh.welcomeError) expect(h.complete).not.toHaveBeenCalled() diff --git a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts index d0927e34f0..cca91259c4 100644 --- a/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/welcome-store.client.spec.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { decodeWelcomeSection, WelcomeNoticeStore } from '../src/client/welcome-store.ts' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, @@ -15,6 +16,13 @@ function ok(value: T) { return { ok: true as const, value } } +function rejected(message: string) { + return { + ok: false as const, + error: new RemoteError('settings/rejected', message, { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE }), + } +} + function namespace(value: unknown = {}, revision = 0) { return { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, @@ -35,10 +43,10 @@ function buildWelcome( api: { describe?: ReturnType; mutate?: ReturnType }, persistence: 'host' | 'memory' = 'host', ) { - const wire = { settings: api } as never - const mirror = new SettingsDescribeMirror(wire, persistence) + const ctx = { remote: { settings: api } } as never + const mirror = new SettingsDescribeMirror(ctx, persistence) const scope = new SettingsScopeController( - wire, + ctx, { namespace: WELCOME_NOTICE_SETTINGS_NAMESPACE, decode: decodeWelcomeSection }, mirror, persistence, @@ -109,11 +117,11 @@ describe('WelcomeNoticeStore', () => { expect(controller.store.getSnapshot()).toEqual({ status: 'loading', acknowledged: false, error: null }) }) - it('reports a failed or refused persistence attempt after its recovery read', async () => { + it('reports a refused persistence attempt after its recovery read', async () => { const describeCall = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()], }))) - const mutate = vi.fn(() => Promise.reject(new Error('disk full'))) + const mutate = vi.fn(() => Promise.resolve(rejected('the settings document is read-only'))) const { mirror, controller } = buildWelcome({ describe: describeCall, mutate }) await mirror.load() await controller.load() diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index c0cdbaf6b7..73e7d06cbd 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" @@ -48,7 +47,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -57,7 +55,6 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@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:^", diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 40ec376432..46529882bc 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -54,7 +54,7 @@ const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ export const inject = [ - 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', ] /** @@ -68,10 +68,10 @@ export function apply(ctx: ClientContext): void { const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS })) const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController( - ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) + ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx) const subagentModelSelection = new SubagentModelSelectionCardController( ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), - ctx.remote.session, + ctx, ) // The credential a card reports is not part of any settings section, so its diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index 9e1b5c2d2a..96e5b8c494 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -1,9 +1,7 @@ /** Staged editor for the Host-owned subagent model allowlist. */ -import type { - ClientRemote, - ModelProviderGroup, -} from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import type { CardShell } from './card-form.ts' @@ -145,11 +143,12 @@ export class SubagentModelSelectionCardController { /** * @param scope - bound `subagent-model-selection` settings scope. - * @param session - Host Session model-catalog face. + * @param ctx - the card plugin's context, whose `remote.session` namespace + * answers the Host model catalog. */ constructor( private readonly scope: SettingsScope, - private readonly session: Pick, + private readonly ctx: ClientContext, ) { this.store = createSnapshotStore(this.projection()) this.unsubscribe = scope.subscribe(() => { @@ -320,15 +319,13 @@ export class SubagentModelSelectionCardController { this.catalogStatus = 'loading' this.catalogPartial = false this.publish() - try { - const response = await this.session.modelCatalog() - if (generation !== this.catalogGeneration) return - if (!response.ok) throw new Error(response.error.message) + const response = await this.ctx.remote.session.modelCatalog() + if (generation !== this.catalogGeneration) return + if (response.ok) { this.catalogGroups = response.value.groups this.catalogPartial = response.value.failures.length > 0 this.catalogStatus = 'ready' - } catch { - if (generation !== this.catalogGeneration) return + } else { this.catalogStatus = 'error' } this.publish() diff --git a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts index 924ba688d5..35990763bb 100644 --- a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts @@ -9,7 +9,9 @@ * covers everything the card shows. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the ctx.remote merge into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { @@ -39,9 +41,6 @@ export interface WebSearchSettings { maxUses?: number } -/** The credentials Remote methods this card reads and writes through. */ -export type WebSearchCredentials = Pick - /** What the credentials domain last reported, and for which reference. */ interface CredentialState { /** Reference this answer describes; a stale response for another one is dropped. */ @@ -82,11 +81,12 @@ export class WebSearchCardController { /** * @param scope - the bound settings scope for the `web-search-deepseek` namespace. - * @param credentials - Remote face used for the credential the section references. + * @param ctx - the card plugin's context, whose `remote.credentials` namespace + * answers for the credential the section references. */ constructor( private readonly scope: SettingsScope, - private readonly credentials: WebSearchCredentials, + private readonly ctx: ClientContext, ) { this.form = new CardForm( scope, @@ -125,14 +125,7 @@ export class WebSearchCardController { this.credential = { ref, configured: false, writable: true } this.store.set(this.projection()) } - let response: Awaited> - try { - response = await this.credentials.describe([ref]) - } catch (_credentialReadFailure) { - // The card stays usable without this: the key control simply reports the - // last state it knew, and a write still reaches the Host. - return - } + const response = await this.ctx.remote.credentials.describe([ref]) if (!response.ok || ref !== refOf(this.scope.getSnapshot())) return const view = response.value[ref] const next: CredentialState = { @@ -174,12 +167,9 @@ export class WebSearchCardController { * @returns whether the Host reports a configured credential afterwards. */ private async writeKey(value: string): Promise { - try { - await this.credentials.set(refOf(this.scope.getSnapshot()), value) - } catch (_credentialWriteFailure) { - // Refusals surface through the re-read below: the Host is the only - // authority on whether the key now exists. - } + // Refusals surface through the re-read below: the Host is the only + // authority on whether the key now exists. + await this.ctx.remote.credentials.set(refOf(this.scope.getSnapshot()), value) await this.readCredential() return this.credential.configured } diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 6bc979abc6..54090a4e1b 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -4,8 +4,8 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { @@ -27,12 +27,14 @@ async function bench(served?: string[]) { const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } })) + const describeCredentials = vi.fn(() => Promise.resolve({ + ok: false, error: new RemoteError('gateway/internal', 'no provider', {}), + })) const models = vi.fn(() => Promise.resolve({ ok: true as const, value: { groups: [], failures: [] }, })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined - ? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } } + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } : { ok: true, value: { @@ -48,9 +50,6 @@ async function bench(served?: string[]) { session: { modelCatalog: models }, settings: { describe: describeSettings }, }) - ctx.provide('connection', { - isLoopback: true, - } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote, @@ -67,7 +66,7 @@ function declareRoot(slots: SlotRegistry): () => void { describe('ui-settings-plugins apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', ]) }) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index ffdb0d8cf3..5f4e13259d 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' -import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts' @@ -46,13 +46,18 @@ function acceptWrites(host: StubSettingsScope): void { }) } +/** The card plugin's context, scripted down to the namespaces a card reaches. */ +function ctxWith(namespaces: object) { + return { remote: namespaces } as never +} + function credentialsApi(configured: boolean) { const describe = vi.fn(() => Promise.resolve({ ok: true as const, value: { DEEPSEEK_API_KEY: { configured, writable: true } }, })) const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) - return { api: { describe, set } as never, describe, set } + return { ctx: ctxWith({ credentials: { describe, set } }), describe, set } } function modelsApi(options: { @@ -67,9 +72,9 @@ function modelsApi(options: { const models = vi.fn(() => Promise.resolve({ ...(options.error === undefined ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } } - : { ok: false as const, error: { code: 'internal' as const, message: options.error, details: {} } }), + : { ok: false as const, error: new RemoteError('gateway/internal', options.error, {}) }), })) - return { api: { modelCatalog: models } as never, models } + return { ctx: ctxWith({ session: { modelCatalog: models } }), models } } function deferred() { @@ -454,7 +459,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, revision: 3, value: { enabled: false, allowedModels: [] }, user: {}, @@ -485,7 +490,7 @@ describe('SubagentModelSelectionCardController', () => { it('starts an empty draft when a ready test scope has no decoded value', () => { const host = stubSettingsScope() - const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx) host.publish({ status: 'ready', writable: true, revision: 0, value: undefined }) const face = controller.inject() @@ -501,7 +506,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() @@ -528,7 +533,7 @@ describe('SubagentModelSelectionCardController', () => { groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], failures: [{ id: 'beta', name: 'Beta', message: 'offline' }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, revision: 5, value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, @@ -561,7 +566,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) const face = controller.inject() await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() }) @@ -581,7 +586,7 @@ describe('SubagentModelSelectionCardController', () => { it('reports a directory error and retries it', async () => { const host = stubSettingsScope() const models = modelsApi({ error: 'offline' }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -597,7 +602,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, revision: 4, value: { enabled: false, allowedModels: [] }, user: {}, @@ -631,7 +636,7 @@ describe('SubagentModelSelectionCardController', () => { const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) host.publish({ status: 'ready', writable: true, revision: 4, value: { enabled: false, allowedModels: [] }, user: {}, @@ -668,7 +673,7 @@ describe('SubagentModelSelectionCardController', () => { }) .mockImplementationOnce(() => refreshed.promise) const controller = new SubagentModelSelectionCardController( - host.scope, { modelCatalog: models }, + host.scope, ctxWith({ session: { modelCatalog: models } }), ) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -707,7 +712,7 @@ describe('SubagentModelSelectionCardController', () => { status: 'ready', writable: true, revision: 4, value: { enabled: false, allowedModels: [] }, user: {}, }) - const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const controller = new SubagentModelSelectionCardController(host.scope, models.ctx) const face = controller.inject() face.toggleEnabled() await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) @@ -747,7 +752,7 @@ describe('SubagentModelSelectionCardController', () => { }, }) const controller = new SubagentModelSelectionCardController( - host.scope, { modelCatalog: models }, + host.scope, ctxWith({ session: { modelCatalog: models } }), ) const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot() await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') }) @@ -773,7 +778,7 @@ describe('SubagentModelSelectionCardController', () => { allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [], } }) }) - const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.api) + const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.ctx) const face = controller.inject() face.save() @@ -796,25 +801,25 @@ describe('SubagentModelSelectionCardController', () => { expect(mutate).toHaveBeenCalledOnce() }) - it('suppresses duplicate directory loads and late resolve or reject settlements', async () => { + it('suppresses duplicate directory loads and late settlements', async () => { const host = stubSettingsScope() host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const pending = deferred() const models = vi.fn(() => pending.promise) - const controller = new SubagentModelSelectionCardController(host.scope, { modelCatalog: models }) + const controller = new SubagentModelSelectionCardController(host.scope, ctxWith({ session: { modelCatalog: models } })) const face = controller.inject() face.toggleEnabled() face.retryCatalog() expect(models).toHaveBeenCalledOnce() controller.dispose() - pending.reject(new Error('late failure')) - await pending.promise.catch(() => undefined) + pending.resolve({ ok: false, error: new RemoteError('gateway/internal', 'late failure', {}) } as never) + await pending.promise const pendingResolve = deferred() const resolving = new SubagentModelSelectionCardController( host.scope, - { modelCatalog: () => pendingResolve.promise }, + ctxWith({ session: { modelCatalog: () => pendingResolve.promise } }), ) const resolvingFace = resolving.inject() resolvingFace.toggleEnabled() @@ -827,7 +832,7 @@ describe('SubagentModelSelectionCardController', () => { it('ignores writes while read-only and scope notifications after disposal', () => { const host = stubSettingsScope() - const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx) host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() @@ -852,7 +857,7 @@ describe('WebSearchCardController', () => { it('reads the credential state for the reference the tab names', async () => { const host = stubSettingsScope() const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) const state = () => controller.inject().hooks.webSearchCard.getSnapshot() await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) @@ -868,7 +873,7 @@ describe('WebSearchCardController', () => { it('writes the staged key through the credentials domain, never the settings section', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, user: {} }) const face = controller.inject() @@ -893,7 +898,7 @@ describe('WebSearchCardController', () => { it('keeps the stored key when the draft is left blank', () => { const host = stubSettingsScope() const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, user: {} }) const face = controller.inject() @@ -908,7 +913,7 @@ describe('WebSearchCardController', () => { it('re-reads when the Host reports the watched reference changed', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, user: {} }) await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) credentials.describe.mockClear() @@ -932,7 +937,7 @@ describe('WebSearchCardController', () => { it('addresses the reference the tab declares rather than the default', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} }) const face = controller.inject() @@ -946,7 +951,7 @@ describe('WebSearchCardController', () => { it('reports a key the Host did not store as a failed save', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, user: {} }) const face = controller.inject() @@ -958,11 +963,15 @@ describe('WebSearchCardController', () => { }) }) - it('keeps the card usable when the credential read fails', async () => { + it('keeps the card usable when the credential read is refused', async () => { const host = stubSettingsScope() - const describe = vi.fn(() => Promise.reject(new Error('offline'))) - const set = vi.fn(() => Promise.reject(new Error('offline'))) - const controller = new WebSearchCardController(host.scope, { describe, set }) + const refusal = () => Promise.resolve({ + ok: false as const, + error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }), + }) + const describe = vi.fn(refusal) + const set = vi.fn(refusal) + const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } })) const face = controller.inject() await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) @@ -982,9 +991,11 @@ describe('WebSearchCardController', () => { const host = stubSettingsScope() const describe = vi.fn(() => Promise.resolve({ ok: false as const, - error: { code: 'internal', message: 'no credential provider', details: {} }, + error: new RemoteError('gateway/internal', 'no credential provider', {}), + })) + const controller = new WebSearchCardController(host.scope, ctxWith({ + credentials: { describe, set: vi.fn() }, })) - const controller = new WebSearchCardController(host.scope, { describe, set: vi.fn() }) await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false) @@ -994,7 +1005,7 @@ describe('WebSearchCardController', () => { const host = stubSettingsScope() acceptWrites(host) const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.api) + const controller = new WebSearchCardController(host.scope, credentials.ctx) host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} }) const face = controller.inject() @@ -1020,7 +1031,7 @@ describe('ConfigurablePluginsTabController', () => { })), }, })) - return { mirror: new SettingsDescribeMirror({ settings: { describe } } as never), describe } + return { mirror: new SettingsDescribeMirror(ctxWith({ settings: { describe } })), describe } } /** Slot ledger stand-in: one stored entry per registered card key. */ diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 6c5bdc2be3..02f6a598bb 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -32,7 +32,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-api-remotes" ], "platform": "web" @@ -49,7 +48,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^" }, @@ -62,8 +60,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0", - "@deepseek-ai/dsh-client-connection": "workspace:^" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 23f289e527..d595952c3c 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -12,9 +12,9 @@ * Export discipline: packages/client/AGENTS.md. */ import type { Context } from '@deepseek-ai/cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' -// Type-only service merge for the connection lifecycle event. -import type {} from '@deepseek-ai/dsh-client-connection/client' +// Type-only: the ctx.remote merge, the fixed Host facts, and the carrier's +// `connection/reset` lifecycle event, all through the assembly package. +import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only pair supplying `$on` and its key face without dragging a build // artifact into the Host graph (rationale beside the same pair in // settings-scope.ts). @@ -33,14 +33,14 @@ export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './ export type { SettingsSchemaService } from './schema.ts' export type { SchemaNode } from './schema.ts' export type { - SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot, SettingsRemote, SettingsWireFace, + SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot, } from './settings-mirror.ts' /** - * Required services: the wire handle for the mirror's reads and the forwarded - * settings invalidation the mirror refreshes on. + * Required services: the Remote namespace the mirror reads through and the + * forwarded settings invalidation it refreshes on. */ -export const inject = ['connection', 'remote', 'remote.settings'] +export const inject = ['remote', 'remote.settings'] /** * Provide the settings-namespace scope service over one shared describe @@ -53,11 +53,10 @@ export const inject = ['connection', 'remote', 'remote.settings'] */ export function apply(ctx: Context): void { const schema = new SettingsSchemaService(ctx) - const connection = ctx.get('connection') as ConnectionHandle - // Captured once here, where `remote.settings` is declared in this plugin's - // own `inject`; the binder hands the same face to every scope it binds. - const wire = { settings: ctx.remote.settings } - const mirror = new SettingsDescribeMirror(wire, connection.isLoopback ? 'host' : 'memory') + // Resolved once here, where `remote` is declared in this plugin's own + // `inject`; the binder hands the same answer to every scope it binds. + const persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory' + const mirror = new SettingsDescribeMirror(ctx, persistence) ctx.effect(() => { const disposers = [ ctx.remote.$on('settings/document-updated', () => { void mirror.load() }), @@ -70,5 +69,5 @@ export function apply(ctx: Context): void { void mirror.ensure() return () => { for (const dispose of disposers) dispose() } }, 'ui-settings: describe mirror invalidations') - new SettingsScopeBinder(ctx, { mirror, schema, wire }) + new SettingsScopeBinder(ctx, { mirror, schema, persistence }) } diff --git a/packages/client/ui-settings/src/client/settings-mirror.ts b/packages/client/ui-settings/src/client/settings-mirror.ts index f7a6c15a22..d26f0b8c94 100644 --- a/packages/client/ui-settings/src/client/settings-mirror.ts +++ b/packages/client/ui-settings/src/client/settings-mirror.ts @@ -9,25 +9,10 @@ * through {@link SettingsDescribeMirror.acceptView}. */ -import type { ClientRemote, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -/** - * The settings Remote methods browser configuration surfaces may reach: the - * redacted read plus merge, replacement, and path-addressed writes. - * Named once here so the consumers share one face instead of each re-deriving - * it from the namespace. - */ -export type SettingsRemote = Pick - -/** Wire face carrying the settings Remote namespace. */ -export interface SettingsWireFace { - /** The settings Remote namespace. */ - settings: SettingsRemote -} - -type SettingsFace = SettingsWireFace - /** The full `settings.describe` answer the mirror serves. */ export interface SettingsDescribeView { /** Every namespace a live Host plugin registered, as the Host reported it. */ @@ -92,11 +77,12 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { private generation = 0 /** - * @param api - settings wire face. + * @param ctx - the providing plugin's context, whose `remote.settings` + * namespace answers the describe read. * @param persistence - client-selected Host persistence; non-loopback pages may remain process-local. */ constructor( - private readonly api: SettingsFace, + private readonly ctx: ClientContext, private readonly persistence: 'host' | 'memory' = 'host', ) { this.store = createSnapshotStore({ @@ -194,7 +180,7 @@ export class SettingsDescribeMirror implements SettingsDescribeFace { const generation = ++this.generation let outcome: { view: SettingsDescribeView } | { failure: string } try { - const response = await this.api.settings.describe() + const response = await this.ctx.remote.settings.describe() outcome = response.ok ? { view: response.value } : { failure: response.error.message } diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 2d728e5154..305c94da37 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -9,7 +9,7 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { - ConnectionHandle, JsonValue, SettingsNamespaceView, SettingsPathOpView, + JsonValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' // Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this @@ -30,9 +30,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' import type {} from '@deepseek-ai/dsh-settings/types' import type { SettingsSchemaService } from './schema.ts' import type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-contract.ts' -import { SettingsDescribeMirror, type SettingsDescribeFace, type SettingsWireFace } from './settings-mirror.ts' - -type SettingsFace = SettingsWireFace +import { SettingsDescribeMirror, type SettingsDescribeFace } from './settings-mirror.ts' /** * One namespace's derived view over the shared describe mirror, plus that @@ -54,14 +52,15 @@ export class SettingsScopeController implements SettingsScope { private pendingRevision: number | undefined /** - * @param api - settings wire face (writes only; reads ride the mirror). + * @param ctx - the providing plugin's context, whose `remote.settings` + * namespace carries this scope's writes (reads ride the mirror). * @param spec - namespace identity and optional narrowing decoder. * @param mirror - the shared describe mirror this scope derives from. * @param persistence - client-selected Host persistence; non-loopback pages may remain process-local. * @param schema - settings-owned schema operations. */ constructor( - private readonly api: SettingsFace, + private readonly ctx: Context, private readonly spec: SettingsScopeSpec, private readonly mirror: SettingsDescribeMirror, private readonly persistence: 'host' | 'memory', @@ -128,13 +127,7 @@ export class SettingsScopeController implements SettingsScope { const generation = ++this.writeGeneration return this.enqueue(async () => { const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision - let response: Awaited> - try { - response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision) - } catch (_settingsWriteFailure) { - await this.recover(generation) - return - } + const response = await this.ctx.remote.settings.mutate(this.spec.namespace, ownedOps, revision) if (!response.ok) { await this.recover(generation) return @@ -238,26 +231,30 @@ declare module '@deepseek-ai/cordis' { export class SettingsScopeBinder extends Service { private readonly mirror: SettingsDescribeMirror private readonly schema: SettingsSchemaService - private readonly wire: SettingsWireFace + private readonly persistence: 'host' | 'memory' + /** + * The PROVIDING fiber, kept because a Service reads `ctx` as its *consumer's* + * fiber: letting a bound scope write through the caller's context would make + * every caller declare `remote.settings` in its own `inject`. + */ + private readonly owner: Context /** * @param ctx - the providing plugin's context. * @param config - the shared describe mirror every bound scope derives from, - * the settings-owned schema operations, and the settings Remote namespace the - * bound scopes write through. The namespace is captured here rather than read - * inside {@link bind}, because a Service reads `ctx` as its *consumer's* - * fiber: reading it there would make every caller declare `remote.settings` - * in its own `inject`. + * the settings-owned schema operations, and the Host persistence the provider + * resolved from `remote.$host`. */ constructor(ctx: Context, config: { mirror: SettingsDescribeMirror schema: SettingsSchemaService - wire: SettingsWireFace + persistence: 'host' | 'memory' }) { super(ctx, 'settingsScope') this.mirror = config.mirror this.schema = config.schema - this.wire = config.wire + this.persistence = config.persistence + this.owner = ctx } /** @@ -283,12 +280,11 @@ export class SettingsScopeBinder extends Service { */ bind(spec: SettingsScopeSpec): SettingsScope { const ctx = this.ctx - const connection = ctx.get('connection') as ConnectionHandle const controller = new SettingsScopeController( - this.wire, + this.owner, spec, this.mirror, - connection.isLoopback ? 'host' : 'memory', + this.persistence, this.schema, ) ctx.effect(() => { diff --git a/packages/client/ui-settings/tests/plugin.client.spec.ts b/packages/client/ui-settings/tests/plugin.client.spec.ts index a5a7a85053..47aad4b686 100644 --- a/packages/client/ui-settings/tests/plugin.client.spec.ts +++ b/packages/client/ui-settings/tests/plugin.client.spec.ts @@ -10,7 +10,6 @@ function bench() { ok: true, value: { writable: true, hasDocument: true, namespaces: [] }, }) const ctx = new Context() - ctx.provide('connection', { api: {}, isLoopback: true } as never) const remote = new TestRemote(ctx, { settings: { describe: describeCall } }) return { ctx, describeCall, remote, fiber: ctx.plugin({ inject: [...inject], apply }) } } diff --git a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts index 92a5c0fa11..3bb67fe3f0 100644 --- a/packages/client/ui-settings/tests/settings-mirror.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-mirror.client.spec.ts @@ -1,18 +1,24 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsDescribeMirror, type SettingsDescribeView } from '../src/client/settings-mirror.ts' -/** What a Remote call answers with: no carrier envelope, and a free-form failure code. */ +/** What a Remote call answers with: no carrier envelope, and a typed failure. */ type Answer = | { ok: true; value: T } - | { ok: false; error: { code: string; message: string; details: object } } + | { ok: false; error: RemoteError } function ok(value: T): Answer { return { ok: true, value } } function rejected(message: string): Answer { - return { ok: false, error: { code: 'settings-rejected', message, details: { ns: 'theme' } } } + return { ok: false, error: new RemoteError('settings/rejected', message, { ns: 'theme' }) } +} + +/** The providing plugin's context, scripted down to the one method the mirror calls. */ +function ctxWith(describeCall: unknown) { + return { remote: { settings: { describe: describeCall } } } as never } function view(ns: string, revision = 0): SettingsNamespaceView { @@ -35,7 +41,7 @@ describe('SettingsDescribeMirror', () => { const describeCall = vi.fn() .mockReturnValueOnce(gate.promise) .mockResolvedValue(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) const first = mirror.load() // Issued before the wire read goes out: covered by that read, no rerun. const early = mirror.load() @@ -56,7 +62,7 @@ describe('SettingsDescribeMirror', () => { .mockResolvedValueOnce(described([view('theme', 2)])) .mockRejectedValueOnce(new Error('host gone')) .mockResolvedValueOnce(rejected('busy')) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.load() expect(mirror.getSnapshot()).toMatchObject({ status: 'ready', error: null }) await mirror.load() @@ -71,7 +77,7 @@ describe('SettingsDescribeMirror', () => { const describeCall = vi.fn() .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.ensure() expect(mirror.getSnapshot()).toMatchObject({ status: 'idle', view: undefined, error: 'offline' }) await mirror.ensure() @@ -81,7 +87,7 @@ describe('SettingsDescribeMirror', () => { it('treats ensure as a no-op once ready', async () => { const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.ensure() await mirror.ensure() await mirror.ensure() @@ -90,7 +96,7 @@ describe('SettingsDescribeMirror', () => { it('memory persistence is terminally unavailable and never touches the wire', async () => { const describeCall = vi.fn() - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never, 'memory') + const mirror = new SettingsDescribeMirror(ctxWith(describeCall), 'memory') await mirror.ensure() await mirror.load() expect(mirror.getSnapshot()).toEqual({ status: 'unavailable', view: undefined, error: null }) @@ -100,7 +106,7 @@ describe('SettingsDescribeMirror', () => { it('acceptView folds one write answer into the held view without a wire read', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described([view('theme', 1), view('locale', 4)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.load() const seen: number[] = [] mirror.subscribe(() => { seen.push(mirror.namespace('theme')?.revision ?? -1) }) @@ -113,14 +119,14 @@ describe('SettingsDescribeMirror', () => { it('acceptView before any answer is a no-op instead of inventing a document', () => { const describeCall = vi.fn() - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) mirror.acceptView(view('theme', 1)) expect(mirror.getSnapshot()).toEqual({ status: 'idle', view: undefined, error: null }) }) it('acceptView appends a namespace the held view has not seen yet', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.load() mirror.acceptView(view('fresh-ns', 0)) expect(mirror.namespace('fresh-ns')).toBeDefined() @@ -132,7 +138,7 @@ describe('SettingsDescribeMirror', () => { // a load() in the one-microtask gap after the rerun check marked a rerun // nobody read, and that refresh never reached the wire. const describeCall = vi.fn().mockResolvedValue(described([view('theme', 1)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) void mirror.load() await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) }) void mirror.load() @@ -144,7 +150,7 @@ describe('SettingsDescribeMirror', () => { it('starts no second run for a load issued inside the loading publish', async () => { const gate = deferred>() const describeCall = vi.fn().mockReturnValue(gate.promise) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) let reentered = false const unsubscribe = mirror.subscribe(() => { if (reentered) return @@ -164,7 +170,7 @@ describe('SettingsDescribeMirror', () => { it('lets the first read cover a write folded inside the loading publish', async () => { const describeCall = vi.fn().mockResolvedValue(described([view('theme', 2)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) const unsubscribe = mirror.subscribe(() => { unsubscribe() mirror.acceptView(view('theme', 2)) @@ -183,7 +189,7 @@ describe('SettingsDescribeMirror', () => { .mockResolvedValueOnce(described([view('theme', 4), view('locale', 1)])) .mockReturnValueOnce(slow.promise) .mockResolvedValueOnce(described([view('theme', 5), view('locale', 2)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) await mirror.load() expect(describeCall).toHaveBeenCalledTimes(1) const stale = mirror.load() @@ -201,7 +207,7 @@ describe('SettingsDescribeMirror', () => { const describeCall = vi.fn() .mockReturnValueOnce(slow.promise) .mockResolvedValueOnce(described([view('theme', 2)])) - const mirror = new SettingsDescribeMirror({ settings: { describe: describeCall } } as never) + const mirror = new SettingsDescribeMirror(ctxWith(describeCall)) const loading = mirror.load() await Promise.resolve() mirror.acceptView(view('theme', 2)) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 25091897f0..b1d1062b78 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import type { JsonValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsSchemaService } from '../src/client/schema.ts' import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts' @@ -20,17 +20,22 @@ const ENVELOPE = z.object({ preference: z.union(['light', 'dark', 'system']).default('system'), }).toJSON() -/** What a Remote call answers with: no carrier envelope, and a free-form failure code. */ +/** What a Remote call answers with: no carrier envelope, and a typed failure. */ type Answer = | { ok: true; value: T } - | { ok: false; error: { code: string; message: string; details: object } } + | { ok: false; error: RemoteError } function ok(value: T): Answer { return { ok: true, value } } function rejected(): Answer { - return { ok: false, error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } } } + return { ok: false, error: new RemoteError('settings/rejected', 'conflict', { ns: 'ui-test' }) } +} + +/** The providing plugin's context, scripted down to the settings namespace. */ +function ctxWith(settings: object) { + return { remote: { settings } } as never } function view(value: JsonValue, revision = 0): SettingsNamespaceView { @@ -57,14 +62,14 @@ function deferred() { return { promise, resolve, reject } } -/** A host-mode mirror plus a controller derived from it, over one fake wire. */ +/** A host-mode mirror plus a controller derived from it, over one scripted context. */ function derivedScope( api: { describe?: ReturnType; mutate?: ReturnType }, spec: { namespace: string; decode?: (section: unknown) => UiTestSettings | undefined } = { namespace: 'ui-test' }, ) { - const wire = { settings: api } as never - const mirror = new SettingsDescribeMirror(wire) - const scope = new SettingsScopeController(wire, spec, mirror, 'host', settingsSchema) + const ctx = ctxWith(api) + const mirror = new SettingsDescribeMirror(ctx) + const scope = new SettingsScopeController(ctx, spec, mirror, 'host', settingsSchema) return { mirror, scope } } @@ -229,10 +234,10 @@ describe('SettingsScopeController', () => { it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4)) const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) - const wire = { settings: { describe: describeCall, mutate } } as never - const mirror = new SettingsDescribeMirror(wire) - const writer = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) - const sibling = new SettingsScopeController(wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + const ctx = ctxWith({ describe: describeCall, mutate }) + const mirror = new SettingsDescribeMirror(ctx) + const writer = new SettingsScopeController(ctx, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + const sibling = new SettingsScopeController(ctx, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) await mirror.load() await writer.set('preference', 'dark') expect(describeCall).toHaveBeenCalledTimes(1) @@ -262,13 +267,13 @@ describe('SettingsScopeController', () => { expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 2 }) }) - it('recovers the latest rejected or thrown write from Host state', async () => { + it('recovers the latest refused write from Host state', async () => { const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'system' }, 2)) .mockResolvedValueOnce(described({ preference: 'light' }, 3)) const mutate = vi.fn() .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(rejected()) const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) await mirror.load() @@ -277,11 +282,11 @@ describe('SettingsScopeController', () => { expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light']) }) - it('does not recover superseded rejected or thrown writes', async () => { + it('does not recover superseded refused writes', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 2)) const mutate = vi.fn() .mockResolvedValueOnce(rejected()) - .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(rejected()) .mockResolvedValueOnce(ok(view({ preference: 'light' }, 3))) const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) const published = trackValues(scope) @@ -415,9 +420,8 @@ describe('SettingsScopeController', () => { return () => {} }, } as never - const wire = { settings: {} } as never const scope = new SettingsScopeController( - wire, { namespace: 'ui-test' }, mirror, 'host', settingsSchema) + ctxWith({}), { namespace: 'ui-test' }, mirror, 'host', settingsSchema) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 1 }) await scope.dispose() @@ -433,10 +437,10 @@ describe('SettingsScopeController', () => { it('keeps a remote browser in memory mode without Host calls', async () => { const describeCall = vi.fn() const mutate = vi.fn() - const wire = { settings: { describe: describeCall, mutate } } as never - const mirror = new SettingsDescribeMirror(wire, 'memory') + const ctx = ctxWith({ describe: describeCall, mutate }) + const mirror = new SettingsDescribeMirror(ctx, 'memory') const scope = new SettingsScopeController( - wire, { namespace: 'ui-test' }, mirror, 'memory', settingsSchema) + ctx, { namespace: 'ui-test' }, mirror, 'memory', settingsSchema) expect(scope.getSnapshot()).toEqual({ status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory', }) @@ -510,17 +514,15 @@ describe('SettingsScopeController', () => { describe('SettingsScopeBinder.bind', () => { it('shares one mirror read across bound scopes and disposes each with its fiber', async () => { const describeCall = vi.fn().mockResolvedValue(described({ preference: 'dark' }, 1)) - const wire = { settings: { describe: describeCall } } - const mirror = new SettingsDescribeMirror(wire as never) + const mirror = new SettingsDescribeMirror(ctxWith({ describe: describeCall })) const ctx = new Context() - ctx.provide('connection', { api: wire, isLoopback: true } as never) let theme!: SettingsScope let locale!: SettingsScope - new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, wire: wire as never }).await() + new TestRemote(ctx, { settings: { describe: describeCall } }) + await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, persistence: 'host' }).await() expect(ctx.settingsScope.describe()).toBe(mirror) const fiber = ctx.plugin({ - inject: ['connection', 'remote', 'settingsScope'], + inject: ['remote', 'settingsScope'], apply: (plugin: Context) => { theme = plugin.settingsScope.bind({ namespace: 'ui-test' }) locale = plugin.settingsScope.bind({ namespace: 'ui-test' }) @@ -539,15 +541,13 @@ describe('SettingsScopeBinder.bind', () => { it('binds a remote browser in memory mode without starting a settings read', async () => { const describeCall = vi.fn() - const wire = { settings: { describe: describeCall } } - const mirror = new SettingsDescribeMirror(wire as never, 'memory') + const mirror = new SettingsDescribeMirror(ctxWith({ describe: describeCall }), 'memory') const ctx = new Context() - ctx.provide('connection', { api: wire, isLoopback: false } as never) let scope!: SettingsScope - new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, wire: wire as never }).await() + new TestRemote(ctx, { settings: { describe: describeCall } }) + await ctx.plugin(SettingsScopeBinder, { mirror, schema: settingsSchema, persistence: 'memory' }).await() const fiber = ctx.plugin({ - inject: ['connection', 'remote', 'settingsScope'], + inject: ['remote', 'settingsScope'], apply: (plugin: Context) => { scope = plugin.settingsScope.bind({ namespace: 'ui-test' }) }, diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 36d1e9778c..44712b7286 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -58,7 +58,7 @@ interface CatalogFetch { } /** Required services: reference source faces plus the tool-row and locale registries. */ -export const inject = ['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote', 'remote.skills'] +export const inject = ['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills'] /** * Client plugin body: register the '/' source, dictionaries, and keyed tool row. diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 89111675f3..1c812e460e 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -18,7 +18,8 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { ClientSessionContext, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { apply, inject } from '../src/client/index.ts' import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' @@ -26,7 +27,7 @@ import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' type SkillRow = { name: string; description: string; whenToUse?: string; modelInvocable?: boolean } type ListResult = | { ok: true; value: { skills: SkillRow[] } } - | { ok: false; error: { code: string; message: string; details: object } } + | { ok: false; error: RemoteFailure } type ListFn = (payload: object, signal?: AbortSignal) => Promise interface PresentationCapture { @@ -63,7 +64,6 @@ async function bench(list: ListFn, addressed?: SessionId) { const ctx = new Context() let captured: InputTriggerSource | undefined ctx.provide('inputTriggers', { registerSource: (src: InputTriggerSource) => { captured = src; return () => {} } }) - ctx.provide('connection', {}) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } @@ -102,13 +102,12 @@ const req = (query: string, signal?: AbortSignal) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote', 'remote.skills']) + expect(inject).toEqual(['inputTriggers', 'sessions', 'slots', 'locale', 'remote', 'remote.skills']) }) it('registers the dedicated skill row and its locale dictionaries', async () => { const ctx = new Context() ctx.provide('inputTriggers', { registerSource: () => () => {} }) - ctx.provide('connection', {}) ctx.provide('sessions', { subagentAddress: () => undefined }) new TestRemote(ctx, { skills: { list: listOk(CATALOG) } }) const presentation = providePresentation(ctx) @@ -146,7 +145,6 @@ describe('apply', () => { // InputTriggerService itself injects 'sessions'; the stub unblocks its fiber. ctx.provide('sessions', {}) await ctx.plugin(InputTriggerService).await() - ctx.provide('connection', {}) new TestRemote(ctx, { skills: { list: listOk(CATALOG) } }) const presentation = providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) @@ -183,10 +181,10 @@ describe('candidates: sessionId addressing', () => { it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => { const { source } = await bench(() => Promise.resolve({ - ok: false, error: { code: 'internal', message: 'boom', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'boom', {}), })) await expect(source.candidates(proj('s1'), req('co'))) - .rejects.toThrow('skills/list failed: internal: boom') + .rejects.toThrow('skills/list failed: gateway/internal: boom') }) it('does not fetch Agent-bound skills for an addressed child', async () => { @@ -243,7 +241,7 @@ describe('catalog cache', () => { const { source } = await bench((payload) => { payloads.push(payload) return fail - ? Promise.resolve({ ok: false as const, error: { code: 'internal', message: 'boom', details: {} } }) + ? Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'boom', {}) }) : listOk(CATALOG)(payload) }) await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('boom') diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 4c85625ce6..015b42c54d 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -70,7 +70,6 @@ async function fullBench(sessions: SessionSummary[]) { const ctx = new Context() const face = sessionsWith(sessions) ctx.provide('sessions', face) - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await provideSlotFaces(ctx) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 52f35b241b..e026945747 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState, SessionSummary, SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -576,7 +576,7 @@ describe('SubagentHeaderLineage', () => { const failed = props(catalog({ entries: [], state: 'error', - error: { code: 'internal', message: 'index down', details: {} }, + error: new RemoteError('gateway/internal', 'index down', {}), })) render() hoverCatalog(screen.getByRole('button', { name: /0 个子代理/ })) diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 64bf442b47..48ace2efa0 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -417,7 +417,7 @@ function dynamicToken(name: string): ThemeTokenInspection { * row. `remote` carries the forwarded settings invalidation that * `ctx.settingsScope.bind(spec)` subscribes to on this context. */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'locale', 'remote', 'settingsScope'] /** * Client plugin body: provide the theme service and register the diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index b030523f43..584aae2719 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -50,8 +50,8 @@ async function bench(isLoopback = true) { section[op.path[0]!] = op.value return Promise.resolve({ ok: true as const, value: namespace() }) }) - ctx.provide('connection', { api: {}, isLoopback } as never) const events = new TestRemote(ctx, { settings: { describe, mutate } }) + events.$host = { home: undefined, isLoopback } await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, events, @@ -88,7 +88,7 @@ function fontSizeFaceOf(slots: SlotRegistry) { describe('ui-theme apply', () => { it('declares the slot and locale services', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'settingsScope']) }) it('provides the service, registers localized copy, and registers both rows (declaration before or after apply)', async () => { diff --git a/packages/client/ui-theme/tests/invariant.client.spec.ts b/packages/client/ui-theme/tests/invariant.client.spec.ts index 59516d9159..71c2097b70 100644 --- a/packages/client/ui-theme/tests/invariant.client.spec.ts +++ b/packages/client/ui-theme/tests/invariant.client.spec.ts @@ -24,7 +24,7 @@ describe('invariant companion', () => { it('client apply provides ctx.theme over the slots/locale edges', async () => { // The feature registers its own Appearance settings row with localized // copy, hence the slots + locale edges. - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'settingsScope']) const ctx = new Context() new SlotRegistry(ctx) ctx.provide('connection', { diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 385dc58edf..b9ae865929 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,6 +1,6 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ -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 type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' @@ -15,16 +15,15 @@ import { searchToolview } from './tool/toolviews/search-row.tsx' import { todoToolview } from './tool/toolviews/todo-row.tsx' import { webToolview } from './tool/toolviews/web-row.tsx' -/** Required services: the slot registry and the Host description used for POSIX `~`. */ -export const inject = ['slots', 'connection'] +/** Required services: the slot registry and the Remote face carrying the Host home used for POSIX `~`. */ +export const inject = ['slots', 'remote'] /** * Mount the whole-Tool renderers and built-in atomic Tool registrations. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - const connection = ctx.get('connection') as ConnectionHandle - const toolInject = () => ({ hooks: { connectionGeneration: connection.generation } }) + const toolInject = () => ({ home: ctx.remote.$host.home }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index ce39262499..bf10b4d3b7 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,4 @@ /** Tool UI slot declarations and their composed component props. */ -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,20 +46,18 @@ export interface ToolCallOwnerProps { export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ -export type ToolConnectionGenerationInjected = { - hooks: { - /** Current Connection generation, bound by the slot renderer. */ - connectionGeneration: ConnectionGenerationState - } +export type ToolHostHomeInjected = { + /** Host account home, absent until the Connection is ready. */ + home: string | undefined } /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index e27656cebb..19df19e054 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/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, ToolConnectionGenerationInjected, ToolDetailsProps, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostHomeInjected, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 6a15997531..1114d29146 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,9 +93,8 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useConnectionGeneration, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, home, t, }: ToolTreeProps) { - const home = useConnectionGeneration(generation => generation?.host.home) const block = node.data.root return ( ) { - const home = useConnectionGeneration(generation => generation?.host.home) + block, cwd, home, t, +}: Pick) { const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 960c81f89b..be428ebf19 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -71,10 +71,6 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('connection', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - }) new TestRemote(runtime.ctx, { session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })), diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index bb0b40b74d..f57005bc88 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -120,10 +120,6 @@ async function bench(snapshot: ChatSnapshot) { ctx.provide('layout', layout as never) ctx.provide('uiWorkspace', {} as never) new TestRemote(ctx, { session: { openWorkspacePath } }) - ctx.provide('connection', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - } as never) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) locale.register(CONVERSATION_NS, { zh: conversationZh, en: conversationEn }) diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 723b2d1bd1..1077ea8ada 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -3,11 +3,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index d61b1fe6b7..032c7c8217 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -361,7 +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', { id: 1, host: { home: '/Users/u' } }) + }), target, '/tmp/ws', '/Users/u') expect(view.getByText('~/notes.md')).toBeTruthy() }) diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 852ffd9ef5..5740154547 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -2,7 +2,6 @@ /** 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 { 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 +22,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ( function props( block: ToolResultNode, selectedCallId?: string, - generation?: ConnectionGeneration, + home?: string, owners?: ToolCallOwnerProps[], ): ToolTreeProps { const snapshot = {} as SessionSnapshot @@ -50,7 +49,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useConnectionGeneration: (selector => selector(generation)) as ToolTreeProps['useConnectionGeneration'], + home, t, } as unknown as ToolTreeProps } @@ -98,7 +97,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() + const view = render() expect(view.getByText('~/docs/a.ts')).toBeTruthy() }) }) diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index b20b689724..e301fc4834 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,5 +1,4 @@ /** Test adapter for the production conversation.details.tool registration. */ -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 +143,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 generation - optional Connection generation carrying the Host home. + * @param home - optional Host account home for POSIX `~` summaries. * @returns a direct-test renderSlot implementation. */ export function renderToolDetails( t: TranslateNS<'conversation'>, - generation?: ConnectionGeneration, + home?: string, ): DetailsSlotProps['renderSlot'] { return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; @@ -158,7 +157,7 @@ export function renderToolDetails( return selector(generation)} + home={home} t={t} /> } diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 4a6cedf03e..2409fd0b9c 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -59,10 +59,6 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('connection', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - }) const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } })) new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -205,10 +201,6 @@ describe('keyed toolview hole through the real machinery', () => { 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', { - isLoopback: false, - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - }) new TestRemote(runtime.ctx, { session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })), diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 4bf4793de0..dd3971bbb8 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' -import { bindSnapshotSelector, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { EMPTY_CONVERSATION_SNAPSHOT, UiConversation, @@ -39,7 +39,6 @@ import { import { createConversationStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index d7f6be2c8a..d33944a914 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,7 +22,6 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -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. @@ -88,10 +87,9 @@ export type DirectoryPickingHooks = PropsHooks workspaces.create(input), - hooks: { directoryFlow: browserFlowSource, connectionGeneration }, + home: ctx.remote.$host.home, + hooks: { directoryFlow: browserFlowSource }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index a4e3eb3880..a460afe23a 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,8 +1,7 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' -import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions, SessionListState, diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index c8a5a15740..a312a9b878 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,11 +820,10 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - useConnectionGeneration, + home, renderSlot, t, }: WorkspaceBrowserProps) { - 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) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 941c251f56..511c6c33f9 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -1,7 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, TestRemote } 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' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -58,9 +58,6 @@ async function bench() { binding, fork, } as never) - ctx.provide('connection', { - generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, - } as never) const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' })) const directoryPicker = { pick: pickDirectory } Object.assign(new TestRemote(ctx), { directoryPicker }) @@ -88,7 +85,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', ]) }) @@ -162,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.hooks.connectionGeneration.getSnapshot()).toBeUndefined() + expect(browser.home).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() @@ -181,7 +178,7 @@ describe('ui-workspace apply', () => { const b = await bench() b.search.mockImplementationOnce(async () => ({ ok: false, - error: { code: 'internal', message: 'index unavailable', details: {} }, + error: new RemoteError('gateway/internal', 'index unavailable', {}), }) as never) declare(b.slots, 'sidebar.workspaces') await b.ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index ea349efcc5..15b0afc345 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -17,7 +17,7 @@ import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, 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' @@ -34,9 +34,6 @@ beforeEach(() => { localStorage.clear() }) async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() runtime.releaseWorkspaceSource() - runtime.ctx.provide('connection', { - 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. const directoryPicker = {} @@ -105,7 +102,7 @@ describe('session rename through the assembled browser', () => { it('a rejected rename keeps the dialog open with the error surfaced', async () => { const runtime = await createRuntime() const rename = vi.fn(async () => ({ - ok: false, error: { code: 'internal', message: 'title write failed', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'title write failed', {}), })) await runtime.sessions.add({ id: SID, diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 22e4062abe..e9933f3ea3 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -1,14 +1,13 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts' @@ -85,7 +84,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - useConnectionGeneration: selector => selector(undefined), + home: undefined, renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?

: null)) as never, t, ...overrides, @@ -110,7 +109,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - useConnectionGeneration: selector => selector({ id: 1, host: { home: '/home/u' } }), + home: '/home/u', }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index 816f78e29c..1c76b8b25f 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -6,11 +6,10 @@ import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' import { zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 3cb8c2adbd..393619bbb7 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -7,7 +7,8 @@ import type { IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { SessionId } from '@deepseek-ai/dsh-session/types' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' @@ -435,20 +436,20 @@ describe('UiWorkspaceService', () => { await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new') expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) b.directoryPicker.onPick = () => Promise.resolve({ - ok: false, error: { code: 'internal', message: 'no chooser', details: {} }, + ok: false, error: new RemoteError('gateway/internal', 'no chooser', {}), }) await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser') b.directoryPicker.onList = () => Promise.resolve({ - ok: false, error: { code: 'directory-unreadable', message: 'denied', details: { path: '/private' } }, + ok: false, error: new RemoteError('directory-picker/unreadable', 'denied', { path: '/private' }), }) const listFailure = b.uiWorkspace.listDirectory('/private') await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) - await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) + await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-picker/unreadable' } }) b.directoryPicker.onCreateDirectory = () => Promise.resolve({ - ok: false, error: { code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' } }, + ok: false, error: new RemoteError('directory-picker/exists', 'taken', { path: '/home/u/new' }), }) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({ - rpcError: { code: 'directory-exists' }, + rpcError: { code: 'directory-picker/exists' }, }) }) }) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index bc27c996d0..45869a5c71 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -415,3 +415,10 @@ export type SessionEvent = { surfaceOp?: SurfaceOp } : object) }[T] + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** The named Session does not exist; produced by every layer that resolves a SessionId. */ + 'session/not-found': { readonly sessionId: SessionId } + } +} diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index 1de340561b..93b6c6d858 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -8,7 +8,7 @@ import type { TeamTaskView as TeamTask, TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16, IconRefreshOutline14, IconTrashOutline16, IconUserOutline16, StateDot, @@ -67,7 +67,11 @@ function taskIds(value: string): TeamTaskId[] { return items(value) as TeamTaskId[] } -function failureText(error: Pick): string { +/** + * One failure line for either carrier: a Remote failure, or a Team business + * rejection whose codes stay local to this seam and never ride the wire. + */ +function failureText(error: { readonly code: string; readonly message: string }): string { return `${error.message} (${error.code})` } diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index 60109f5b24..64a798f998 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -5,6 +5,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { TeamMemberView as TeamRosterMember, TeamTaskId } from '@deepseek-ai/dsh-experimental-agent-team/client' import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' import { TeamAction, type TeamActionInjected } from '../src/client/TeamAction.tsx' import { inject, mountAgentTeamUi } from '../src/client/mount.ts' @@ -51,7 +52,7 @@ async function bench(options: { const remote = new RemoteService(ctx) const failure = { ok: false as const, - error: { code: 'internal', message: 'offline', details: {} }, + error: new RemoteError('gateway/internal', 'offline', {}), } const view = { members: [{ @@ -204,18 +205,18 @@ describe('ui-team browser plugin', () => { it('returns Remote carrier failures unchanged', async () => { const view = await bench({ remoteFailure: 'view' }) const viewActions = (view.entry()!.inject as unknown as () => TeamActionInjected)() - await expect(viewActions.load(SESSION)).resolves.toEqual({ + await expect(viewActions.load(SESSION)).resolves.toMatchObject({ ok: false, - error: { code: 'internal', message: 'offline', details: {} }, + error: { code: 'gateway/internal', message: 'offline' }, }) const update = await bench({ remoteFailure: 'update' }) const updateActions = (update.entry()!.inject as unknown as () => TeamActionInjected)() await expect(updateActions.updateTask(SESSION, { taskId: TASK_ID, expectedRevision: 1, action: 'delete', - })).resolves.toEqual({ + })).resolves.toMatchObject({ ok: false, - error: { code: 'internal', message: 'offline', details: {} }, + error: { code: 'gateway/internal', message: 'offline' }, }) }) diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index c1c7bb99f8..51d59f1732 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TeamTaskId, TeamTaskView as TeamTask, TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { TeamAction, type TeamActionInjected, type TeamActionProps, type TeamActionResult, @@ -64,8 +64,8 @@ function taskRejected(message: string): TeamTaskActionResult { } } -function remoteFailure(message: string): { ok: false; error: { code: 'internal'; message: string; details: {} } } { - return { ok: false, error: { code: 'internal', message, details: {} } } +function remoteFailure(message: string): TeamActionResult { + return { ok: false, error: new RemoteError('gateway/internal', message, {}) } } function props(actions: TeamActionInjected, sessionId: SessionId = SESSION): TeamActionProps { @@ -443,7 +443,7 @@ describe('TeamAction', () => { fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) await screen.findByText('Implement runtime') fireEvent.click(screen.getByRole('button', { name: /完成/u })) - expect(await screen.findByText('task reload failed (internal)')).toBeTruthy() + expect(await screen.findByText('task reload failed (gateway/internal)')).toBeTruthy() expect(screen.queryByText(zh.conflict)).toBeNull() first.unmount() @@ -461,7 +461,7 @@ describe('TeamAction', () => { fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Edited' } }) fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) - expect(await screen.findByText('dependency reload failed (internal)')).toBeTruthy() + expect(await screen.findByText('dependency reload failed (gateway/internal)')).toBeTruthy() expect(screen.queryByText(zh.conflict)).toBeNull() }) @@ -521,7 +521,7 @@ describe('TeamAction', () => { }) const first = render() fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText('load failed (internal)')).toBeTruthy() + expect(await screen.findByText('load failed (gateway/internal)')).toBeTruthy() first.unmount() const createTask = vi.fn(() => Promise.resolve(remoteFailure('create failed'))) @@ -532,7 +532,7 @@ describe('TeamAction', () => { fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Task' } }) fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: 'Description' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) - expect(await screen.findByText('create failed (internal)')).toBeTruthy() + expect(await screen.findByText('create failed (gateway/internal)')).toBeTruthy() second.unmount() const pending = Promise.withResolvers() @@ -632,7 +632,7 @@ describe('TeamAction', () => { expect(screen.queryByRole('button', { name: '保存' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: /编辑/u })) fireEvent.click(screen.getByRole('button', { name: '保存' })) - expect(await screen.findByText('edit failed (internal)')).toBeTruthy() + expect(await screen.findByText('edit failed (gateway/internal)')).toBeTruthy() fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Saved edit' } }) fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } }) @@ -666,7 +666,7 @@ describe('TeamAction', () => { fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) - expect(await screen.findByText('dependency transport failed (internal)')).toBeTruthy() + expect(await screen.findByText('dependency transport failed (gateway/internal)')).toBeTruthy() }) it('skips the dependency mutation when an edit keeps the same blockers', async () => { diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index 64e550dc48..bf97dbffa3 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -196,7 +196,7 @@ function stubWorker(): { id: 1, failure: { kind: 'remote', - code: 'session-not-found', + code: 'session/not-found', message: 'fixture Session is absent', details: { sessionId: 'session-1' }, }, @@ -211,7 +211,7 @@ function stubWorker(): { }, { message: 'fixture Session is absent', dshRemoteStreamFailure: { - kind: 'remote', code: 'session-not-found', details: { sessionId: 'session-1' }, + kind: 'remote', code: 'session/not-found', details: { sessionId: 'session-1' }, }, }) } diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 6c3e49abed..a7c39b524e 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -48,7 +48,6 @@ "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", @@ -58,7 +57,6 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", diff --git a/packages/extensions/cordis-client-runner/src/client/inspect-registry.ts b/packages/extensions/cordis-client-runner/src/client/inspect-registry.ts index 729278a498..bb21d74edf 100644 --- a/packages/extensions/cordis-client-runner/src/client/inspect-registry.ts +++ b/packages/extensions/cordis-client-runner/src/client/inspect-registry.ts @@ -3,9 +3,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { CordisInspectProviderManifest, CordisInspectQueryRequest, CordisInspectQueryResolution, - CordisInspectRequestId, JsonValue, + CordisInspectRequestId, JsonValue, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' /** Context supplied to a Client inspect provider query. */ export interface ClientCordisInspectQueryContext { diff --git a/packages/extensions/cordis-client-runner/src/client/orchestrator.ts b/packages/extensions/cordis-client-runner/src/client/orchestrator.ts index b0b713f014..604fc59d83 100644 --- a/packages/extensions/cordis-client-runner/src/client/orchestrator.ts +++ b/packages/extensions/cordis-client-runner/src/client/orchestrator.ts @@ -17,8 +17,8 @@ import type { DynamicCordisResolveAck, DynamicCordisRunResolution, DynamicCordisRunResponse, + SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { errorDetails } from './runtime.ts' import type { CordisErrorDetails, CordisObservable, DynamicCordisPackageRunner } from './runtime.ts' diff --git a/packages/extensions/cordis-client-runner/src/client/runtime.ts b/packages/extensions/cordis-client-runner/src/client/runtime.ts index f89d2220af..c16e420ffb 100644 --- a/packages/extensions/cordis-client-runner/src/client/runtime.ts +++ b/packages/extensions/cordis-client-runner/src/client/runtime.ts @@ -18,8 +18,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { Loader } from '@deepseek-ai/cordis-plugin-loader' import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, DynamicCordisPackage, + SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { DynamicCordisStyles, evaluateClientHalf, DYNAMIC_CLIENT_REDIRECTS } from './evaluator.ts' diff --git a/packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts b/packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts index 7f3ba4eb19..9b9ed0ccc5 100644 --- a/packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts @@ -10,9 +10,8 @@ import { describe, expect, it, vi } from 'vitest' import type { ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, - DynamicCordisClientSource, DynamicCordisHostHalfResult, DynamicCordisResolveAck, + DynamicCordisClientSource, DynamicCordisHostHalfResult, DynamicCordisResolveAck, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { CordisRunOrchestrator } from '../src/client/orchestrator.ts' import type { CordisUserRunRequest } from '../src/client/orchestrator.ts' import type { DynamicCordisLoadResult, DynamicCordisPackageRunner } from '../src/client/runtime.ts' diff --git a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts index 4144d97bd0..008067e4ab 100644 --- a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts @@ -13,9 +13,8 @@ import { describe, expect, it, vi } from 'vitest' import InvariantService from '@deepseek-ai/dsh-invariants' import type { ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, + DynamicCordisInvokeResult, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import type { DynamicCordisInvokeResult } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: resolves the `ctx.remote.$on` surface. import type {} from '@deepseek-ai/dsh-api-gateway/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' diff --git a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts index 02eac311df..93cd0d3373 100644 --- a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts @@ -15,9 +15,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Loader } from '@deepseek-ai/cordis-plugin-loader' import { describe, expect, it, vi } from 'vitest' import type { - CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, + CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { DYNAMIC_CLIENT_REDIRECTS } from '../src/client/evaluator.ts' diff --git a/packages/extensions/cordis-client-runner/tsconfig.json b/packages/extensions/cordis-client-runner/tsconfig.json index f1ac2c60ca..8f34d66195 100644 --- a/packages/extensions/cordis-client-runner/tsconfig.json +++ b/packages/extensions/cordis-client-runner/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../api/remotes/tsconfig.client.json" }, - { - "path": "../../client/connection/tsconfig.client.json" - }, { "path": "../../client/modules" }, diff --git a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx index 4e96d15c94..490c37e803 100644 --- a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx @@ -9,7 +9,7 @@ import { import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type { CordisRunActivity } from '@deepseek-ai/dsh-cordis-client-runner/client' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { CordisInventoryRow } from './dynamic-port.ts' import type { CordisPanelFace } from './slots.ts' import type { CordisKey } from './locales.ts' diff --git a/packages/extensions/ui-cordis/src/client/dynamic-port.ts b/packages/extensions/ui-cordis/src/client/dynamic-port.ts index 346346b153..fcc820263b 100644 --- a/packages/extensions/ui-cordis/src/client/dynamic-port.ts +++ b/packages/extensions/ui-cordis/src/client/dynamic-port.ts @@ -1,6 +1,6 @@ /** Host operations used directly by the frame-wide Cordis panel. */ -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { CordisDynamicPluginId, DynamicCordisInventoryRow, } from './events.ts' diff --git a/packages/extensions/ui-cordis/src/client/run-card-index.ts b/packages/extensions/ui-cordis/src/client/run-card-index.ts index df9b63db9a..50d82b5677 100644 --- a/packages/extensions/ui-cordis/src/client/run-card-index.ts +++ b/packages/extensions/ui-cordis/src/client/run-card-index.ts @@ -1,6 +1,6 @@ /** Session-local ownership index for Package business views on `cordis_run` cards. */ -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, diff --git a/packages/extensions/ui-cordis/src/client/slots.ts b/packages/extensions/ui-cordis/src/client/slots.ts index 42b2c987a0..51fe163159 100644 --- a/packages/extensions/ui-cordis/src/client/slots.ts +++ b/packages/extensions/ui-cordis/src/client/slots.ts @@ -1,6 +1,6 @@ /** Injected faces and the Package-owned `tool.view.cordis` slot declaration. */ -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { CordisRunActivity, CordisRunFailure, CordisUserRunRequest, DynamicCordisLivePackage, diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 37b6795a13..c862fa2352 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,7 +7,7 @@ */ import { Context } from '@deepseek-ai/cordis' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { GenerateOptions, LlmConfigurableProvider, @@ -615,7 +615,7 @@ export class LlmRuntime extends TypertRemoteService { * @param request - endpoint, protocol, and one-shot credential to use. * @param signal - caller cancellation supplied by the Remote carrier. * @returns advertised models in endpoint order. - * @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails. + * @throws RemoteError with `llm/model-discovery-rejected` when discovery refuses or fails. */ @Remote('discoverModels') async remoteDiscoverModels( @@ -626,14 +626,15 @@ export class LlmRuntime extends TypertRemoteService { try { return await this.discoverModels(settingsNs, request, signal) } catch (error: unknown) { - throw new TypertRemoteFailure({ - code: 'model-discovery-failed', - message: error instanceof Error ? error.message : String(error), - details: { + throw new RemoteError( + 'llm/model-discovery-rejected', + error instanceof Error ? error.message : String(error), + { settingsNs, ...request.baseURL === undefined ? {} : { baseURL: request.baseURL }, }, - }) + { cause: error }, + ) } } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 438fcaa67b..4303538892 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -255,13 +255,13 @@ export interface LlmModelDiscoveryOperation extends LlmModelDiscoveryRequest { signal?: AbortSignal } -/** Stable failure returned by the `llm/discoverModels` Remote method. */ -export interface LlmModelDiscoveryError { - readonly code: 'model-discovery-failed' - readonly message: string - readonly details: { - readonly settingsNs: string - readonly baseURL?: string +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** A draft provider interrogation refused or failed. */ + 'llm/model-discovery-rejected': { + readonly settingsNs: string + readonly baseURL?: string + } } } diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index 0fbe8255e6..e647b4052b 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -283,22 +283,18 @@ describe('model discovery registry', () => { { baseURL: 'https://gateway.example/v1' }, signal, )).rejects.toMatchObject({ - failure: { - code: 'model-discovery-failed', - message: 'endpoint offline', - details: { settingsNs: 'llm-example', baseURL: 'https://gateway.example/v1' }, - }, + code: 'llm/model-discovery-rejected', + message: 'endpoint offline', + details: { settingsNs: 'llm-example', baseURL: 'https://gateway.example/v1' }, }) await expect(ctx.llm.remoteDiscoverModels( 'llm-example', { provider: 'known-route' }, signal, )).rejects.toMatchObject({ - failure: { - code: 'model-discovery-failed', - message: 'provider refused', - details: { settingsNs: 'llm-example' }, - }, + code: 'llm/model-discovery-rejected', + message: 'provider refused', + details: { settingsNs: 'llm-example' }, }) }) diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 729e6f68a3..5053f2aff3 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -16,56 +16,47 @@ import { chmod, cp, readdir, readFile, rm, stat } from 'node:fs/promises' import { dirname, isAbsolute, join, resolve } from 'node:path' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { expandHomePath } from '@deepseek-ai/dsh-home-paths' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { METADATA_FILE, renderPresetMetadata } from './metadata.ts' import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' -/** A preset id that cannot be used as a directory name under a root. */ -export class InvalidPresetIdError extends Error { - constructor( - /** The rejected id. */ - readonly presetId: string, - ) { - super( - `agent-presets: preset id ${JSON.stringify(presetId)} must match ${String(PRESET_ID)} — ` - + 'the id is a directory name, so anything else could escape the preset root', - ) - } +/** + * Refuse one authoring request the deployment does not allow. + * @param presetId - what the caller tried to change, for the diagnostic. + * @param reason - why authoring is refused. + * @returns the failure to throw. + */ +function notWritable(presetId: string, reason: string): RemoteError<'agent-preset/read-only'> { + return new RemoteError( + 'agent-preset/read-only', + `agent-presets: preset "${presetId}" cannot be written: ${reason}`, + { agentPreset: presetId, reason }, + ) } -/** A copy target that is already occupied — a copy never overwrites. */ -export class PresetExistsError extends Error { - constructor( - /** The id that is already taken. */ - readonly presetId: string, - ) { - super( - `agent-presets: preset "${presetId}" already exists — ` - + 'a copy never overwrites; delete the existing preset first or choose another id', - ) - } -} - -/** Authoring was attempted where the deployment allows none. */ -export class PresetNotWritableError extends Error { - constructor( - /** What the caller tried to change, for the diagnostic. */ - readonly presetId: string, - reason: string, - ) { - super(`agent-presets: preset "${presetId}" cannot be written: ${reason}`) - } +/** + * Refuse a copy onto an id something already occupies. Both the roster check + * and the on-disk check answer with it, so a taken id reads the same either way. + * @param presetId - the id that is already taken. + * @returns the failure to throw. + */ +export function presetExists(presetId: string): RemoteError<'agent-preset/invalid'> { + const reason = `preset "${presetId}" already exists — ` + + 'a copy never overwrites; delete the existing preset first or choose another id' + return new RemoteError('agent-preset/invalid', `agent-presets: ${reason}`, { agentPreset: presetId, reason }) } /** * The root locally authored presets are written to. * @param roots - the configured roots in precedence order. + * @param presetId - the preset the caller is authoring, named by the refusal. * @returns the absolute path of the first `user` root. * @throws when the deployment configured no writable root. */ -export function writableRoot(roots: readonly PresetRoot[]): string { +export function writableRoot(roots: readonly PresetRoot[], presetId: string): string { const root = roots.find(candidate => candidate.trust === 'user') if (root === undefined) { - throw new PresetNotWritableError('', 'this deployment configures no user-writable preset root') + throw notWritable(presetId, 'this deployment configures no user-writable preset root') } return resolve(expandHomePath(root.path)) } @@ -139,12 +130,16 @@ export async function copyComposition( id: string, name?: string, ): Promise { - if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id) - const dir = join(writableRoot(roots), id) + if (!PRESET_ID.test(id)) { + const reason = `preset id ${JSON.stringify(id)} must match ${String(PRESET_ID)} — ` + + 'the id is a directory name, so anything else could escape the preset root' + throw new RemoteError('agent-preset/invalid', `agent-presets: ${reason}`, { agentPreset: id, reason }) + } + const dir = join(writableRoot(roots, id), id) // The roster check upstream only sees discovered presets; a directory with // no composition file still occupies the name and deserves a readable // refusal rather than a filesystem error code. - if (await occupied(dir)) throw new PresetExistsError(id) + if (await occupied(dir)) throw presetExists(id) try { await cp(dirname(source.path), dir, { recursive: true, dereference: true, force: false, errorOnExist: true, @@ -184,13 +179,13 @@ export async function deleteComposition( preset: AgentPreset, ): Promise { if (preset.trust !== 'user') { - throw new PresetNotWritableError(preset.id, 'it ships with the deployment') + throw notWritable(preset.id, 'it ships with the deployment') } - const dir = join(writableRoot(roots), preset.id) + const dir = join(writableRoot(roots, preset.id), preset.id) // Belt and braces over the id pattern: the resolved directory must still be // the one the writable root owns, whatever discovery reported. if (!isAbsolute(preset.path) || !preset.path.startsWith(dir)) { - throw new PresetNotWritableError(preset.id, 'it does not live under the writable preset root') + throw notWritable(preset.id, 'it does not live under the writable preset root') } await rm(dir, { recursive: true, force: true }) } diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 160e34cfa8..cc568fc6e8 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -24,94 +24,34 @@ import { stat } from 'node:fs/promises' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { AgentPresetDocument, AgentPresetErrorDetailsMap, AgentPresetRoster } from './types.ts' +import type { AgentPresetDocument, AgentPresetRoster } from './types.ts' import type {} from '@deepseek-ai/dsh-session-projection' // Type-only: resolves the registry notification emitted after scope reparenting. import type {} from '@deepseek-ai/dsh-tools' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' -import { - copyComposition, deleteComposition, readComposition, - InvalidPresetIdError, PresetExistsError, PresetNotWritableError, -} from './authoring.ts' +import { copyComposition, deleteComposition, presetExists, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' -import { - PresetLockedError, PresetMountError, UnknownPresetError, - type AgentPreset, type Config, type PresetRoot, -} from './preset.ts' +import type { AgentPreset, Config, PresetRoot } from './preset.ts' import { agentPresetProjectionDefinition } from './session.ts' export type * from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' -/** Construct one typed preset failure for the Remote carrier. */ -function remotePresetFailure( - code: Code, - message: string, - details: AgentPresetErrorDetailsMap[Code], -): TypertRemoteFailure { - return new TypertRemoteFailure({ code, message, details }) -} - -/** Map one preset rejection to its stable Remote code and details. */ -function presetFailure(error: unknown, agentPreset: string): TypertRemoteFailure | undefined { - if (error instanceof UnknownPresetError) { - return remotePresetFailure( - 'agent-preset-not-found', - error.message, - { agentPreset: error.presetId, available: [...error.available] }, - ) - } - if (error instanceof PresetMountError) { - return remotePresetFailure( - 'agent-preset-invalid', - error.message, - { agentPreset: error.presetId, reason: error.reason }, - ) - } - if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { - return remotePresetFailure( - 'agent-preset-invalid', - error.message, - { agentPreset: error.presetId, reason: error.message }, - ) - } - if (error instanceof PresetNotWritableError) { - return remotePresetFailure( - 'agent-preset-read-only', - error.message, - { agentPreset, reason: error.message }, - ) - } - if (error instanceof PresetLockedError) { - return remotePresetFailure( - 'agent-preset-locked', - `session "${error.sessionId}" has already started; its agent preset is fixed`, - { sessionId: error.sessionId, agentPreset: error.presetId }, - ) - } - return undefined -} - /** Refuse an empty preset id before invoking a domain operation. */ function validatePresetId(value: string, field: 'agentPreset' | 'from'): void { if (value.length === 0) { - throw remotePresetFailure('bad-request', `${field} must be a non-empty string`, {}) + throw new RemoteError('gateway/bad-request', `${field} must be a non-empty string`, {}) } } -/** Throw the stable preset failure or the caller's operation-specific fallback. */ -function rejectPreset(error: unknown, agentPreset: string, fallbackMessage: string): never { - throw presetFailure(error, agentPreset) ?? remotePresetFailure('internal', fallbackMessage, {}) -} - /** The user-writable slice of this plugin's config. */ export interface AgentPresetSettings { /** Preset mounted when a session names none. */ @@ -131,12 +71,8 @@ export { inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor, type JoinedPresetMount, type PresetMount, } from './mount.ts' -export { - copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError, - PresetNotWritableError, readComposition, writableRoot, -} from './authoring.ts' +export { copyComposition, deleteComposition, readComposition, writableRoot } from './authoring.ts' export { agentPresetProjectionDefinition } from './session.ts' -export { PresetLockedError, PresetMountError, UnknownPresetError } from './preset.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts' declare module '@deepseek-ai/cordis' { @@ -343,7 +279,12 @@ export class AgentPresets extends TypertRemoteService { const presets = await this.list() const found = presets.find(preset => preset.id === wanted) if (found === undefined) { - throw new UnknownPresetError(wanted, presets.map(preset => preset.id)) + const available = presets.map(preset => preset.id) + throw new RemoteError( + 'agent-preset/not-found', + `agent-presets: preset "${wanted}" not found (available: ${available.join(', ') || 'none'})`, + { agentPreset: wanted, available }, + ) } return found } @@ -361,7 +302,11 @@ export class AgentPresets extends TypertRemoteService { private async resolveMountable(id?: string): Promise { const preset = await this.resolve(id) if (preset.broken !== undefined) { - throw new PresetMountError(preset.id, preset.broken) + throw new RemoteError( + 'agent-preset/invalid', + `agent-presets: preset "${preset.id}" failed to mount: ${preset.broken}`, + { agentPreset: preset.id, reason: preset.broken }, + ) } return preset } @@ -495,23 +440,19 @@ export class AgentPresets extends TypertRemoteService { * One preset's composition text with the roster row it belongs to. * @param agentPreset - the preset id. * @returns the composition beside its trust and published metadata. - * @throws {TypertRemoteFailure} `bad-request` for an empty id, or - * `agent-preset-not-found` when no configured root supplies it. + * @throws {RemoteError} `gateway/bad-request` for an empty id, or + * `agent-preset/not-found` when no configured root supplies it. */ @Remote('read') async readDocument(agentPreset: string): Promise { validatePresetId(agentPreset, 'agentPreset') - try { - const preset = await this.resolve(agentPreset) - return { - agentPreset: preset.id, - trust: preset.trust, - content: await this.read(preset.id), - ...preset.name === undefined ? {} : { name: preset.name }, - ...preset.description === undefined ? {} : { description: preset.description }, - } - } catch (error: unknown) { - rejectPreset(error, agentPreset, `agent preset "${agentPreset}": ${String(error)}`) + const preset = await this.resolve(agentPreset) + return { + agentPreset: preset.id, + trust: preset.trust, + content: await this.read(preset.id), + ...preset.name === undefined ? {} : { name: preset.name }, + ...preset.description === undefined ? {} : { description: preset.description }, } } @@ -536,7 +477,7 @@ export class AgentPresets extends TypertRemoteService { // since a user directory named like a shipped preset is shadowed by it. // The disk check inside copyComposition only sees the writable root. if ((await this.list()).some(preset => preset.id === id)) { - throw new PresetExistsError(id) + throw presetExists(id) } await copyComposition(this.resolvedRoots, source, id, name) // A settled mount under this id can only be stale (its preset was deleted @@ -551,18 +492,14 @@ export class AgentPresets extends TypertRemoteService { * @param id - the new preset id. * @param name - the copy's optional display name. * @returns once the copy is stored. - * @throws {TypertRemoteFailure} with the corresponding stable preset code - * and details when the copy is refused. + * @throws {RemoteError} with the corresponding stable preset code and + * details when the copy is refused. */ @Remote('copy') async remoteExportCopy(from: string, id: string, name?: string): Promise { validatePresetId(from, 'from') validatePresetId(id, 'agentPreset') - try { - await this.copy(from, id, name) - } catch (error: unknown) { - rejectPreset(error, id, `agent preset "${id}": ${String(error)}`) - } + await this.copy(from, id, name) } /** @@ -593,17 +530,13 @@ export class AgentPresets extends TypertRemoteService { * Delete one preset through the Remote API. * @param id - the preset id. * @returns once the preset is deleted. - * @throws {TypertRemoteFailure} with the corresponding stable preset code - * and details when deletion is refused. + * @throws {RemoteError} with the corresponding stable preset code and + * details when deletion is refused. */ @Remote('deletePreset') async remoteExportDelete(id: string): Promise { validatePresetId(id, 'agentPreset') - try { - await this.remove(id) - } catch (error: unknown) { - rejectPreset(error, id, `agent preset "${id}": ${String(error)}`) - } + await this.remove(id) } /** @@ -689,8 +622,8 @@ export class AgentPresets extends TypertRemoteService { * @param agent - the session's live agent, resolved from the wire identity. * @param agentPreset - the preset to compose the agent from instead. * @returns the preset id that was recorded. - * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`, - * `agent-preset-not-found`, or `agent-preset-invalid` when refused. + * @throws {RemoteError} with `gateway/bad-request`, `agent-preset/locked`, + * `agent-preset/not-found`, or `agent-preset/invalid` when refused. */ @Remote('select') async select(agent: Agent, agentPreset: string): Promise { @@ -701,8 +634,6 @@ export class AgentPresets extends TypertRemoteService { this.switches.set(agent.id, guard) try { return await turn - } catch (error: unknown) { - return rejectPreset(error, agentPreset, `failed to select agent preset "${agentPreset}": ${String(error)}`) } finally { if (this.switches.get(agent.id) === guard) this.switches.delete(agent.id) } @@ -717,7 +648,11 @@ export class AgentPresets extends TypertRemoteService { const boundary = this.selfCtx.sessionProjections.stateOf(agent.session, 'turnBoundary') if (boundary !== undefined && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) { - throw new PresetLockedError(agent.id, agentPreset) + throw new RemoteError( + 'agent-preset/locked', + `session "${agent.id}" has already started; its agent preset is fixed`, + { sessionId: agent.id, agentPreset }, + ) } const preset = await this.recompose(agent.ctx, agentPreset) // Recorded only after the swap committed: the log states what the agent @@ -774,7 +709,12 @@ export class AgentPresets extends TypertRemoteService { // refreshes instead of trusting a composition older than its stamp. const stamp = await compositionStamp(preset.path) if (stamp === undefined) { - throw new PresetMountError(preset.id, `composition file is unreadable: ${preset.path}`) + const reason = `composition file is unreadable: ${preset.path}` + throw new RemoteError( + 'agent-preset/invalid', + `agent-presets: preset "${preset.id}" failed to mount: ${reason}`, + { agentPreset: preset.id, reason }, + ) } await mountPreset(scope.ctx, preset) return { key, scope, stamp } diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index d9a92d6a27..04b037be23 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -19,7 +19,8 @@ import { Context, type Fiber } from '@deepseek-ai/cordis' import { Include } from '@deepseek-ai/cordis-plugin-include' import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader' import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' -import { PresetMountError, type AgentPreset } from './preset.ts' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { AgentPreset } from './preset.ts' import { classifyRowSpecifier } from './specifier.ts' /** What one mounted subtree publishes about itself for the audit to read. */ @@ -406,6 +407,12 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi // Swallows only this subtree's teardown failure. The mount error below is // the actionable one, and the discarded fiber is unreachable either way. } - throw new PresetMountError(preset.id, `${mountDetail(error)} (${preset.path})`, { cause: error }) + const reason = `${mountDetail(error)} (${preset.path})` + throw new RemoteError( + 'agent-preset/invalid', + `agent-presets: preset "${preset.id}" failed to mount: ${reason}`, + { agentPreset: preset.id, reason }, + { cause: error }, + ) } } diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index bb0dd18496..5ffa58aee4 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -1,7 +1,5 @@ /** Agent-preset vocabulary shared by discovery, mounting, and consumers. */ -import type { SessionId } from '@deepseek-ai/dsh-session/types' - /** * Where a preset's composition came from. A `system` preset ships with the * deployment; a `user` preset was authored locally, by a person or by an @@ -70,50 +68,3 @@ export interface Config { */ includeUserRoot: boolean } - -/** - * No configured root supplies the requested preset. - * - * Separate from a mount failure because the two mean different things to a - * caller: an unknown id is a bad request, while an unusable composition is a - * broken preset the deployment must fix. - */ -export class UnknownPresetError extends Error { - constructor( - /** The id that was requested. */ - readonly presetId: string, - /** Ids the roster does supply, for the caller to offer instead. */ - readonly available: readonly string[], - ) { - super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`) - } -} - -/** - * The session's composition is fixed: its conversation has started, so its - * history was produced under the preset it runs and swapping the composition - * would leave logged tool calls the new one cannot make. - */ -export class PresetLockedError extends Error { - constructor( - /** The session whose composition is already fixed. */ - readonly sessionId: SessionId, - /** The preset that was refused. */ - readonly presetId: string, - ) { - super(`agent-presets: session "${sessionId}" has already started; its agent preset is fixed`) - } -} - -/** A preset exists but its composition cannot be installed. */ -export class PresetMountError extends Error { - constructor( - /** The preset whose composition failed. */ - readonly presetId: string, - /** Why it failed, without this package's own message prefix. */ - readonly reason: string, - options?: ErrorOptions, - ) { - super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options) - } -} diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts index fc567f9ad5..dde42cfaf3 100644 --- a/packages/preset/agent-presets/src/types.ts +++ b/packages/preset/agent-presets/src/types.ts @@ -31,30 +31,18 @@ export interface AgentPresetRoster { readonly authorable: boolean } -/** Stable details for agent-preset failures returned by the Remote namespace. */ -export interface AgentPresetErrorDetailsMap { - /** A required preset id is empty. */ - 'bad-request': Record - /** No configured root supplies the requested id. */ - 'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] } - /** The id is unusable, already taken, or its composition cannot be installed. */ - 'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string } - /** The preset ships with the deployment and is not the user's to change. */ - 'agent-preset-read-only': { readonly agentPreset: string; readonly reason: string } - /** The session's conversation has started, so its composition is fixed. */ - 'agent-preset-locked': { readonly sessionId: SessionId; readonly agentPreset: string } - /** The preset operation failed without a caller-actionable classification. */ - internal: Record -} - -/** One agent-preset refusal as a client reads it. */ -export type AgentPresetError = { - [Code in keyof AgentPresetErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: AgentPresetErrorDetailsMap[Code] +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No configured root supplies the requested id. */ + 'agent-preset/not-found': { readonly agentPreset: string; readonly available: readonly string[] } + /** The id is unusable, already taken, or its composition cannot be installed. */ + 'agent-preset/invalid': { readonly agentPreset: string; readonly reason: string } + /** The preset ships with the deployment and is not the user's to change. */ + 'agent-preset/read-only': { readonly agentPreset: string; readonly reason: string } + /** The session's conversation has started, so its composition is fixed. */ + 'agent-preset/locked': { readonly sessionId: SessionId; readonly agentPreset: string } } -}[keyof AgentPresetErrorDetailsMap] +} /** One preset's composition text beside the row it belongs to. */ export interface AgentPresetDocument { diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 6695450a3a..9d5d052724 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -15,7 +15,7 @@ import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh- import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { - COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, + COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' import type { Config } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-agent-presets/types' @@ -369,9 +369,10 @@ describe('composing from a broken preset', () => { const scoped = await rosterWith('- id: x\n name: [unclosed\n') // The refusal happens before the loader ever sees the file, so every - // unloadable shape gets the same early PresetMountError — and a rejected - // setup rolls the whole agent creation back. - await expect(agentOn(scoped, 'sess-broken', 'damaged')).rejects.toThrow(PresetMountError) + // unloadable shape gets the same early agent-preset/invalid — and a + // rejected setup rolls the whole agent creation back. + await expect(agentOn(scoped, 'sess-broken', 'damaged')) + .rejects.toMatchObject({ code: 'agent-preset/invalid' }) await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/) expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0) }) @@ -769,7 +770,7 @@ describe('editing a composition file', () => { ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise } await expect(racer.ensureStanding({ id: 'unstampable', trust: 'user', path })) - .rejects.toThrow(PresetMountError) + .rejects.toMatchObject({ code: 'agent-preset/invalid' }) expect(livePresetMounts().filter(mount => mount.presetId === 'unstampable')).toHaveLength(0) }) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index b7ece833cc..9a9992e3d3 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -18,7 +18,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { TypertRemoteFailure, type RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { remoteErrorOf, type RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import { afterEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { COMPOSITION_FILE, METADATA_FILE } from '@deepseek-ai/dsh-agent-presets' import type { Config } from '@deepseek-ai/dsh-agent-presets' @@ -40,9 +40,9 @@ async function remoteFailure(operation: Promise): Promise { const resolve = vi.spyOn(ctx.agentPresets, 'resolve') await expect(ctx.agentPresets.readDocument('')) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) expect(resolve).not.toHaveBeenCalled() }) @@ -203,7 +203,7 @@ describe('reading one composition', () => { const failure = await remoteFailure(ctx.agentPresets.readDocument('never-existed')) expect(failure).toMatchObject({ - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', details: { agentPreset: 'never-existed', }, @@ -213,17 +213,12 @@ describe('reading one composition', () => { expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard'])) }) - it('keeps the legacy internal diagnostic for an unrelated read failure', async () => { + it('raises an unrelated read failure exactly as it was thrown', async () => { const ctx = await harness() - vi.spyOn(ctx.agentPresets, 'read').mockRejectedValueOnce(new Error('disk failed')) + const thrown = new Error('disk failed') + vi.spyOn(ctx.agentPresets, 'read').mockRejectedValueOnce(thrown) - const failure = await remoteFailure(ctx.agentPresets.readDocument('standard')) - - expect(failure).toEqual({ - code: 'internal', - message: 'agent preset "standard": Error: disk failed', - details: {}, - }) + await expect(ctx.agentPresets.readDocument('standard')).rejects.toBe(thrown) }) }) @@ -238,7 +233,7 @@ describe('authoring over Remote', () => { () => ctx.agentPresets.remoteExportCopy('standard', ''), () => ctx.agentPresets.remoteExportDelete(''), ]) { - await expect(operation()).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + await expect(operation()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } expect(copy).not.toHaveBeenCalled() expect(remove).not.toHaveBeenCalled() @@ -266,7 +261,7 @@ describe('authoring over Remote', () => { const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('never-existed', 'mine')) expect(failure).toMatchObject({ - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', details: { agentPreset: 'never-existed', }, @@ -281,14 +276,14 @@ describe('authoring over Remote', () => { const invalid = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', '../escape')) expect(invalid).toMatchObject({ - code: 'agent-preset-invalid', + code: 'agent-preset/invalid', details: { agentPreset: '../escape' }, }) expect(reasonOf(invalid)).toContain('must match') const occupied = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'minimal')) expect(occupied).toMatchObject({ - code: 'agent-preset-invalid', + code: 'agent-preset/invalid', details: { agentPreset: 'minimal' }, }) expect(reasonOf(occupied)).toContain('already exists') @@ -305,7 +300,7 @@ describe('authoring over Remote', () => { const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'mine')) expect(failure).toMatchObject({ - code: 'agent-preset-read-only', + code: 'agent-preset/read-only', details: { agentPreset: 'mine' }, }) expect(reasonOf(failure)).toContain('no user-writable preset root') @@ -316,30 +311,25 @@ describe('authoring over Remote', () => { const readOnly = await remoteFailure(ctx.agentPresets.remoteExportDelete('standard')) expect(readOnly).toMatchObject({ - code: 'agent-preset-read-only', + code: 'agent-preset/read-only', details: { agentPreset: 'standard' }, }) expect(reasonOf(readOnly)).toContain('ships with the deployment') const missing = await remoteFailure(ctx.agentPresets.remoteExportDelete('never-existed')) expect(missing).toMatchObject({ - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', details: { agentPreset: 'never-existed' }, }) expect(availableOf(missing)).toEqual(expect.arrayContaining(['minimal', 'standard'])) }) - it('keeps the legacy internal diagnostic for an unrelated authoring failure', async () => { + it('raises an unrelated authoring failure exactly as it was thrown', async () => { const ctx = await harness() - vi.spyOn(ctx.agentPresets, 'copy').mockRejectedValueOnce(new Error('copy failed')) + const thrown = new Error('copy failed') + vi.spyOn(ctx.agentPresets, 'copy').mockRejectedValueOnce(thrown) - const failure = await remoteFailure(ctx.agentPresets.remoteExportCopy('standard', 'mine')) - - expect(failure).toEqual({ - code: 'internal', - message: 'agent preset "mine": Error: copy failed', - details: {}, - }) + await expect(ctx.agentPresets.remoteExportCopy('standard', 'mine')).rejects.toBe(thrown) }) }) @@ -350,7 +340,7 @@ describe('switching one session\'s composition', () => { const recompose = vi.spyOn(ctx.agentPresets, 'recompose') await expect(ctx.agentPresets.select(agent, '')) - .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) expect(recompose).not.toHaveBeenCalled() }) @@ -403,8 +393,8 @@ describe('switching one session\'s composition', () => { const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal')) - expect(failure).toEqual({ - code: 'agent-preset-locked', + expect(failure).toMatchObject({ + code: 'agent-preset/locked', message: 'session "sel-locked" has already started; its agent preset is fixed', details: { sessionId: SessionId('sel-locked'), agentPreset: 'minimal' }, }) @@ -419,7 +409,7 @@ describe('switching one session\'s composition', () => { const failure = await remoteFailure(ctx.agentPresets.select(agent, 'nope')) expect(failure).toMatchObject({ - code: 'agent-preset-not-found', + code: 'agent-preset/not-found', details: { agentPreset: 'nope' }, }) expect(availableOf(failure)).toEqual(expect.arrayContaining(['minimal', 'standard'])) @@ -454,23 +444,18 @@ describe('switching one session\'s composition', () => { const failure = await remoteFailure(ctx.agentPresets.select(agent, 'damaged')) expect(failure).toMatchObject({ - code: 'agent-preset-invalid', + code: 'agent-preset/invalid', details: { agentPreset: 'damaged' }, }) expect(reasonOf(failure)).not.toBe('') }) - it('keeps the legacy internal diagnostic for an unrelated switch failure', async () => { + it('raises an unrelated switch failure exactly as it was thrown', async () => { const ctx = await harness() const agent = await agentOn(ctx, 'sel-internal', 'standard') - vi.spyOn(ctx.agentPresets, 'recompose').mockRejectedValueOnce(new Error('mount failed')) + const thrown = new Error('mount failed') + vi.spyOn(ctx.agentPresets, 'recompose').mockRejectedValueOnce(thrown) - const failure = await remoteFailure(ctx.agentPresets.select(agent, 'minimal')) - - expect(failure).toEqual({ - code: 'internal', - message: 'failed to select agent preset "minimal": Error: mount failed', - details: {}, - }) + await expect(ctx.agentPresets.select(agent, 'minimal')).rejects.toBe(thrown) }) }) diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 425c7b9b03..d7c86cca06 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -54,6 +54,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -67,6 +68,7 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", @@ -104,6 +106,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -120,6 +123,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index ea02674413..d40aa1d9f4 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -6,11 +6,11 @@ * @module @deepseek-ai/dsh-subagent/control-types */ +import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { z as zCore } from 'zod' /** * Client-minted identity of one browser prompt, persisted on the exact accepted @@ -95,6 +95,21 @@ export type SubagentAddress = | { readonly mode: 'continuable' } ) +/** + * One browser-encoded upload as the Session prompt wire carries it: the shared + * attachment vocabulary under the content-block tag. + */ +export interface EncodedImagePromptBlock extends EncodedImageAttachment { + readonly type: 'image' +} + +/** + * One block a browser prompt may carry. The encoded upload is accepted by the + * wire and refused by the Host, so the Client narrows nothing: a caller that + * attaches an image is answered, not silently stripped. + */ +export type SubagentPromptContentPart = ContentBlock | EncodedImagePromptBlock + /** One human message addressed to a continuable direct child. */ export interface SubagentPromptRequest { /** Identity persisted on the accepted message, minted before the call. */ @@ -103,8 +118,8 @@ export interface SubagentPromptRequest { readonly childSessionId: SessionId /** Required discriminator retained from the browser control address. */ readonly mode: 'continuable' - /** Content delivered as the child's user message. */ - readonly content: ContentBlock[] + /** Content proposed as the child's user message; images are refused. */ + readonly content: readonly SubagentPromptContentPart[] /** Optional browser zone sampled for this exact human prompt. */ readonly clientTimeZone?: string } @@ -122,26 +137,27 @@ export interface SubagentInterruptReceipt { /** * Failure details the control surface answers with. The catalog read, the * prompt, and the interrupt produce these codes; a Client fabricates - * `subagent-not-resumable` and `subagent-delivery-unavailable` for a one-shot + * `subagent/not-resumable` and `subagent/delivery-unavailable` for a one-shot * address it refuses before the call, so both planes read one vocabulary. */ -export interface SubagentControlErrorDetailsMap { - 'bad-request': { readonly issues: zCore.core.$ZodIssue[] } - cancelled: Record - 'invalid-time-zone': { readonly value: string } - 'subagent-parent-unavailable': { readonly parentSessionId: SessionId } - 'subagent-not-resumable': { readonly childSessionId: SessionId } - 'subagent-unauthorized': { readonly childSessionId: SessionId } - 'subagent-delivery-unavailable': { readonly childSessionId: SessionId } - 'subagent-projections-unavailable': Record - internal: Record -} - -/** One subagent control failure, returned without a carrier error. */ -export type SubagentControlError = { - [Code in keyof SubagentControlErrorDetailsMap]: { - readonly code: Code - readonly message: string - readonly details: SubagentControlErrorDetailsMap[Code] +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** A browser-supplied zone is neither UTC nor a canonical IANA name. */ + 'subagent/invalid-time-zone': { readonly value: string } + /** No live Agent carries the addressed parent session. */ + 'subagent/parent-unavailable': { readonly parentSessionId: SessionId } + /** The addressed child cannot take a continuation. */ + 'subagent/not-resumable': { readonly childSessionId: SessionId } + /** The claimed parent does not own the addressed child. */ + 'subagent/unauthorized': { readonly childSessionId: SessionId } + /** + * The continuation admits no attachment. `reason` names the refused plane + * for the caller's copy, as the Session prompt's attachment refusals do. + */ + 'subagent/attachment-unsupported': { readonly childSessionId: SessionId; readonly reason: string } + /** The child exists but its inbox cannot admit the message now. */ + 'subagent/delivery-unavailable': { readonly childSessionId: SessionId } + /** The deployment mounts no session-projection registry. */ + 'subagent/projections-unavailable': {} } -}[keyof SubagentControlErrorDetailsMap] +} diff --git a/packages/subagent/subagent/src/control.ts b/packages/subagent/subagent/src/control.ts index 661a43153a..d3e68aeb39 100644 --- a/packages/subagent/subagent/src/control.ts +++ b/packages/subagent/subagent/src/control.ts @@ -7,17 +7,15 @@ */ import type { Context } from '@deepseek-ai/cordis' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' -import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import type { - SubagentCatalog, SubagentControlErrorDetailsMap, SubagentListEntry, + SubagentCatalog, SubagentListEntry, SubagentPromptContentPart, } from './control-types.ts' import { SubagentError } from './error.ts' -/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ - const SESSION_ID_SCHEMA = z.string().min(1) const CONTROL_ID_SCHEMAS = { 'subagent.list': z.object({ parentSessionId: SESSION_ID_SCHEMA }), @@ -33,48 +31,12 @@ const CONTROL_ID_SCHEMAS = { }), } as const -/** - * Validate and canonicalize one browser-supplied IANA zone at the wire boundary. - * @param value - the browser's reported zone name. - * @returns the canonical zone, or `undefined` when the name is unusable. - */ -export function canonicalClientTimeZone(value: string): string | undefined { - if (value.length === 0 || value.trim() !== value - || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined - try { - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) - .resolvedOptions().timeZone - /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ - if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined - return canonical - } catch { - // Intl rejects unsupported zone names; the caller maps that parser rejection. - return undefined - } -} - -/** - * Refuse one Remote call with a stable business failure the carrier preserves. - * @param code - declared caller-facing code. - * @param message - human-readable refusal. - * @param details - that code's declared detail payload. - * @returns Never — the failure is thrown. - * @throws {TypertRemoteFailure} always. - */ -export function rejectControl( - code: Code, - message: string, - details: SubagentControlErrorDetailsMap[Code], -): never { - throw new TypertRemoteFailure({ code, message, details }) -} - /** * Apply the subagent payload checks that are stricter than generated * branded-string codecs. * @param method - method name carried in the failure message. * @param payload - decoded control fields to validate. - * @throws {TypertRemoteFailure} `bad-request` with the original Zod issues. + * @throws {RemoteError} `gateway/bad-request` with the original Zod issues. */ export function validateControlRequest( method: keyof typeof CONTROL_ID_SCHEMAS, @@ -82,12 +44,42 @@ export function validateControlRequest( ): void { const parsed = CONTROL_ID_SCHEMAS[method].safeParse(payload) if (!parsed.success) { - return rejectControl('bad-request', `invalid payload for ${method}`, { - issues: parsed.error.issues, - }) + throw new RemoteError('gateway/bad-request', `invalid payload for ${method}`, { issues: parsed.error.issues }) } } +/** + * Admit the content one continuation may deliver, refusing every image. + * + * The blocks become the child's user message verbatim, and this surface admits + * no attachment: nothing here registers encoded bytes with the attachment + * service, so an image would reach the child as a reference nothing resolves. + * The wire accepts the encoded upload so this refusal — not a Client that + * strips the block — is what the caller is answered with. Other block types + * still cross unnarrowed. + * @param childSessionId - the addressed child, named by the refusal. + * @param content - blocks the caller asked to deliver. + * @returns the admitted blocks, in order, as the durable content vocabulary. + * @throws {RemoteError} `subagent/attachment-unsupported` when any block is an image. + */ +export function admitPromptContent( + childSessionId: SessionId, + content: readonly SubagentPromptContentPart[], +): ContentBlock[] { + const admitted: ContentBlock[] = [] + for (const block of content) { + if (block.type === 'image') { + throw new RemoteError( + 'subagent/attachment-unsupported', + 'subagent continuation does not accept images', + { childSessionId, reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, + ) + } + admitted.push(block) + } + return admitted +} + /** * Project one durable listing onto the catalog view, replacing each row's * store-derived activity with the live Agent driver's status and reporting @@ -118,20 +110,21 @@ export function catalogView( * @param error - the thrown value. * @param signal - the caller's cancellation. * @returns Never — the refusal is thrown. - * @throws {TypertRemoteFailure} always. + * @throws {RemoteError} always. */ export function rejectCatalogRead(error: unknown, signal: AbortSignal): never { if (isCancellation(error, signal)) { - return rejectControl('cancelled', 'subagent catalog read was cancelled', {}) + throw new RemoteError('gateway/cancelled', 'subagent catalog read was cancelled', {}, { cause: error }) } if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { - return rejectControl( - 'subagent-projections-unavailable', + throw new RemoteError( + 'subagent/projections-unavailable', 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', {}, + { cause: error }, ) } - return rejectControl('internal', 'subagent catalog read failed', {}) + throw new RemoteError('gateway/internal', 'subagent catalog read failed', {}, { cause: error }) } /** @@ -142,37 +135,44 @@ export function rejectCatalogRead(error: unknown, signal: AbortSignal): never { * @param childSessionId - the addressed child. * @param signal - the caller's cancellation. * @returns Never — the refusal is thrown. - * @throws {TypertRemoteFailure} always. + * @throws {RemoteError} always. */ export function rejectPrompt(error: unknown, childSessionId: SessionId, signal: AbortSignal): never { if (isCancellation(error, signal)) { - return rejectControl('cancelled', 'subagent prompt was cancelled', {}) + throw new RemoteError('gateway/cancelled', 'subagent prompt was cancelled', {}, { cause: error }) } if (error instanceof SubagentError) { switch (error.code) { case 'NOT_RESUMABLE': - return rejectControl('subagent-not-resumable', 'subagent cannot be resumed', { childSessionId }) + throw new RemoteError( + 'subagent/not-resumable', + 'subagent cannot be resumed', + { childSessionId }, + { cause: error }, + ) case 'UNAUTHORIZED': - return rejectControl( - 'subagent-unauthorized', + throw new RemoteError( + 'subagent/unauthorized', 'subagent does not belong to this parent', { childSessionId }, + { cause: error }, ) case 'DRAINING': case 'ACTIVATION_CLOSING': case 'CONTINUATION_UNAVAILABLE': case 'PERSISTENCE_UNAVAILABLE': - return rejectControl( - 'subagent-delivery-unavailable', + throw new RemoteError( + 'subagent/delivery-unavailable', 'subagent follow-up is temporarily unavailable', { childSessionId }, + { cause: error }, ) // A code outside the admission vocabulary is not the caller's move to make. default: break } } - return rejectControl('internal', 'subagent prompt failed', {}) + throw new RemoteError('gateway/internal', 'subagent prompt failed', {}, { cause: error }) } function isCancellation(error: unknown, signal: AbortSignal): boolean { diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 444f4ee857..bce712991f 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -36,10 +36,10 @@ import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { - canonicalClientTimeZone, catalogView, rejectCatalogRead, rejectControl, rejectPrompt, - validateControlRequest, + admitPromptContent, catalogView, rejectCatalogRead, rejectPrompt, validateControlRequest, } from './control.ts' import type { SubagentCatalog, @@ -398,9 +398,9 @@ export class SubagentRuntime extends TypertRemoteService { * @param parentSessionId - parent session whose direct children are listed. * @param signal - carrier cancellation forwarded to Session queries. * @returns the catalog view for that parent. - * @throws {TypertRemoteFailure} `bad-request` for an empty parent id, - * `cancelled` for an aborted read, `subagent-projections-unavailable` when - * the deployment has no projection registry, otherwise `internal`. + * @throws {RemoteError} `gateway/bad-request` for an empty parent id, + * `gateway/cancelled` for an aborted read, `subagent/projections-unavailable` when + * the deployment has no projection registry, otherwise `gateway/internal`. */ @Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise { @@ -421,29 +421,30 @@ export class SubagentRuntime extends TypertRemoteService { * @param request - durable address, minted identity, content, and optional browser zone. * @param signal - carrier cancellation, owning the call until inbox acceptance. * @returns the accepted message's inbox identity. - * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`, - * `subagent-parent-unavailable`, `subagent-not-resumable`, - * `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or - * `internal`. + * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-unsupported`, + * `subagent/invalid-time-zone`, `subagent/parent-unavailable`, + * `subagent/not-resumable`, `subagent/unauthorized`, + * `subagent/delivery-unavailable`, `gateway/cancelled`, or `gateway/internal`. */ @Remote('prompt') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise { const { parentSessionId, childSessionId, clientTimeZone } = request validateControlRequest('subagent.prompt', request) + const content = admitPromptContent(childSessionId, request.content) const canonicalTimeZone = clientTimeZone === undefined ? undefined : canonicalClientTimeZone(clientTimeZone) if (clientTimeZone !== undefined && canonicalTimeZone === undefined) { - return rejectControl( - 'invalid-time-zone', + throw new RemoteError( + 'subagent/invalid-time-zone', 'clientTimeZone must be UTC or a valid IANA Area/Location name', { value: clientTimeZone }, ) } const parent = this.ctx.get('agents')?.get(parentSessionId) if (parent === undefined) { - return rejectControl( - 'subagent-parent-unavailable', + throw new RemoteError( + 'subagent/parent-unavailable', `parent session "${parentSessionId}" is not live`, { parentSessionId }, ) @@ -453,7 +454,6 @@ export class SubagentRuntime extends TypertRemoteService { rpcId: request.requestId, ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), } - const content: ContentBlock[] = [...request.content] try { return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) } } catch (error: unknown) { @@ -471,9 +471,9 @@ export class SubagentRuntime extends TypertRemoteService { * @param parentSessionId - durable direct parent whose authority is claimed. * @param mode - required continuable-address discriminator. * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. - * @throws {TypertRemoteFailure} `bad-request` for an empty id, - * `subagent-unauthorized` when the address does not own the live target, - * otherwise `internal`. + * @throws {RemoteError} `gateway/bad-request` for an empty id, + * `subagent/unauthorized` when the address does not own the live target, + * otherwise `gateway/internal`. */ @Remote('interruptByParent') interruptByParent( @@ -486,13 +486,14 @@ export class SubagentRuntime extends TypertRemoteService { this.interrupt(childSessionId, { kind: 'user', parentSessionId }) } catch (error: unknown) { if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') { - return rejectControl( - 'subagent-unauthorized', + throw new RemoteError( + 'subagent/unauthorized', 'subagent does not belong to this parent', { childSessionId }, + { cause: error }, ) } - return rejectControl('internal', 'subagent interrupt failed', {}) + throw new RemoteError('gateway/internal', 'subagent interrupt failed', {}, { cause: error }) } return { accepted: true } } diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index dd04d549b1..97641511f4 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -47,7 +47,7 @@ function promptRequest(clientTimeZone?: string) { function emptyIdFailure(method: string, field: string) { return { - code: 'bad-request', + code: 'gateway/bad-request', message: `invalid payload for ${method}`, details: { issues: [{ @@ -68,7 +68,7 @@ describe('subagent catalog Remote', () => { const listChildren = vi.spyOn(subagents, 'listChildren') await expect(subagents.remoteExportList(SessionId(''), signal)) - .rejects.toMatchObject({ failure: emptyIdFailure('subagent.list', 'parentSessionId') }) + .rejects.toMatchObject(emptyIdFailure('subagent.list', 'parentSessionId')) expect(listChildren).not.toHaveBeenCalled() }) @@ -117,25 +117,23 @@ describe('subagent catalog Remote', () => { aborted.abort() listChildren.mockRejectedValue(new Error('read stopped')) await expect(subagents.remoteExportList(PARENT, aborted.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) listChildren.mockRejectedValue(new SubagentError('cancelled', 'CANCELLED')) await expect(subagents.remoteExportList(PARENT, signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) listChildren.mockRejectedValue( new SubagentError('no registry', 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE'), ) await expect(subagents.remoteExportList(PARENT, signal)).rejects.toMatchObject({ - failure: { - code: 'subagent-projections-unavailable', - message: expect.stringContaining('sessionProjections') as unknown as string, - }, + code: 'subagent/projections-unavailable', + message: expect.stringContaining('sessionProjections') as unknown as string, }) listChildren.mockRejectedValue(new Error('disk gone')) await expect(subagents.remoteExportList(PARENT, signal)) - .rejects.toMatchObject({ failure: { code: 'internal', message: 'subagent catalog read failed' } }) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'subagent catalog read failed' }) }) }) @@ -153,7 +151,7 @@ describe('subagent prompt Remote', () => { ] for (const { field, request } of cases) { await expect(subagents.prompt(request, signal)) - .rejects.toMatchObject({ failure: emptyIdFailure('subagent.prompt', field) }) + .rejects.toMatchObject(emptyIdFailure('subagent.prompt', field)) } expect(followup).not.toHaveBeenCalled() }) @@ -168,6 +166,23 @@ describe('subagent prompt Remote', () => { expect(followup.mock.calls[0]?.[2]).toEqual(content) }) + it('refuses an image the wire accepts, whatever else the content carries', async () => { + const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const followup = vi.spyOn(subagents, 'followup') + // Both variants the widened wire admits: the browser's encoded upload and a + // durable reference. The Client narrows neither, so the Host answers both. + const encoded = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AAA=' } + const durable = { type: 'image' as const, attachment: { attachmentId: 'att-1' } as never } + for (const image of [encoded, durable]) { + const content = [{ type: 'text' as const, text: 'look at this' }, image] + await expect(subagents.prompt({ ...promptRequest(), content }, signal)).rejects.toMatchObject({ + code: 'subagent/attachment-unsupported', + details: { childSessionId: CHILD, reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, + }) + } + expect(followup).not.toHaveBeenCalled() + }) + it('delivers the content under the caller-minted identity and canonical browser zone', async () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) const followup = vi.spyOn(subagents, 'followup').mockResolvedValue('m-1' as MessageId) @@ -200,7 +215,7 @@ describe('subagent prompt Remote', () => { await expect(subagents.prompt(promptRequest('UTC'), signal)).resolves.toEqual({ messageId: 'm-3' }) for (const zone of ['', ' UTC', 'Shanghai', 'Nowhere/Nowhere']) { await expect(subagents.prompt(promptRequest(zone), signal)).rejects.toMatchObject({ - failure: { code: 'invalid-time-zone', details: { value: zone } }, + code: 'subagent/invalid-time-zone', details: { value: zone }, }) } }) @@ -210,7 +225,7 @@ describe('subagent prompt Remote', () => { const followup = vi.spyOn(subagents, 'followup') await expect(subagents.prompt(promptRequest(), signal)).rejects.toMatchObject({ - failure: { code: 'subagent-parent-unavailable', details: { parentSessionId: PARENT } }, + code: 'subagent/parent-unavailable', details: { parentSessionId: PARENT }, }) expect(followup).not.toHaveBeenCalled() }) @@ -219,21 +234,21 @@ describe('subagent prompt Remote', () => { const { subagents } = await bench({ [PARENT]: { status: 'idle' } }) const followup = vi.spyOn(subagents, 'followup') const cases: readonly [string, string][] = [ - ['NOT_RESUMABLE', 'subagent-not-resumable'], - ['UNAUTHORIZED', 'subagent-unauthorized'], - ['DRAINING', 'subagent-delivery-unavailable'], - ['ACTIVATION_CLOSING', 'subagent-delivery-unavailable'], - ['NO_PROVIDER', 'internal'], + ['NOT_RESUMABLE', 'subagent/not-resumable'], + ['UNAUTHORIZED', 'subagent/unauthorized'], + ['DRAINING', 'subagent/delivery-unavailable'], + ['ACTIVATION_CLOSING', 'subagent/delivery-unavailable'], + ['NO_PROVIDER', 'gateway/internal'], ] for (const [thrown, code] of cases) { followup.mockRejectedValue(new SubagentError('refused', thrown)) await expect(subagents.prompt(promptRequest(), signal)) - .rejects.toMatchObject({ failure: { code } }) + .rejects.toMatchObject({ code }) } followup.mockRejectedValue(new Error('inbox exploded')) await expect(subagents.prompt(promptRequest(), signal)) - .rejects.toMatchObject({ failure: { code: 'internal', message: 'subagent prompt failed' } }) + .rejects.toMatchObject({ code: 'gateway/internal', message: 'subagent prompt failed' }) }) it('answers a caller-cancelled delivery as cancelled rather than a failure', async () => { @@ -245,7 +260,7 @@ describe('subagent prompt Remote', () => { }) await expect(subagents.prompt(promptRequest(), aborted.signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) }) it('preserves a cancellation reported by the continuation operation', async () => { @@ -254,7 +269,7 @@ describe('subagent prompt Remote', () => { .mockRejectedValue(new SubagentError('stopped', 'CANCELLED')) await expect(subagents.prompt(promptRequest(), signal)) - .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) }) }) @@ -269,9 +284,7 @@ describe('subagent interrupt Remote', () => { ] as const) { const field = childSessionId.length === 0 ? 'childSessionId' : 'parentSessionId' expect(() => subagents.interruptByParent(childSessionId, parentSessionId, 'continuable')) - .toThrow(expect.objectContaining({ - failure: emptyIdFailure('subagent.interrupt', field), - })) + .toThrow(expect.objectContaining(emptyIdFailure('subagent.interrupt', field))) } expect(interrupt).not.toHaveBeenCalled() }) @@ -290,12 +303,16 @@ describe('subagent interrupt Remote', () => { interrupt.mockImplementation(() => { throw new SubagentError('not yours', 'UNAUTHORIZED') }) expect(() => subagents.interruptByParent(CHILD, PARENT, 'continuable')).toThrow( - expect.objectContaining({ failure: { code: 'subagent-unauthorized', message: expect.any(String) as unknown as string, details: { childSessionId: CHILD } } }), + expect.objectContaining({ + code: 'subagent/unauthorized', + message: expect.any(String) as unknown as string, + details: { childSessionId: CHILD }, + }), ) interrupt.mockImplementation(() => { throw new Error('boom') }) expect(() => subagents.interruptByParent(CHILD, PARENT, 'continuable')).toThrow( - expect.objectContaining({ failure: { code: 'internal', message: 'subagent interrupt failed', details: {} } }), + expect.objectContaining({ code: 'gateway/internal', message: 'subagent interrupt failed', details: {} }), ) }) }) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 4c86dd8250..4a90ca128c 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../core/agent" }, @@ -59,6 +62,9 @@ { "path": "../../typert/protocol" }, + { + "path": "../../util/time" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 427a2a6b2d..43e895f31a 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 6a0b88387f..60c5c078eb 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -45,7 +45,7 @@ export type { StubSettingsScope } from './settings-scope.ts' export { scriptedSettingsRemote } from './settings-remote.ts' export type { ScriptedNamespace, ScriptedSettingsRemote } from './settings-remote.ts' export { TestWorkspaces } from './workspaces.ts' -export { TestRemote } from './remote.ts' +export { RemoteError, TestRemote } from './remote.ts' export { chatSnapshot, conversationSnapshot, sessionSnapshot, workspaceSnapshot, } from './fixtures.ts' diff --git a/packages/test-support/client-runtime/src/remote.ts b/packages/test-support/client-runtime/src/remote.ts index 9a0249245c..34737e2295 100644 --- a/packages/test-support/client-runtime/src/remote.ts +++ b/packages/test-support/client-runtime/src/remote.ts @@ -1,6 +1,11 @@ /** Test-owned Remote face: `$on` subscriptions with an explicit test event driver. */ import type { Context } from '@deepseek-ai/cordis' +// Value re-export for spec-side failure construction: the api-remotes facade +// cannot carry it — its src top-level imports owner /remote lib artifacts, so a +// value import from a spec would load the unbuilt assembly chain. +export { RemoteError } from '@deepseek-ai/dsh-typert-protocol' + /** * Remote service test double for the forwarded-event path. Feature specs need * `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded @@ -21,6 +26,12 @@ import type { Context } from '@deepseek-ai/cordis' export class TestRemote { private readonly subscriptions = new Map void>>() + /** + * Fixed Host facts mirrored from the production `ctx.remote.$host`. Plain + * mutable field: a spec assigns it to script a non-loopback or homed Host. + */ + $host: { home: string | undefined; isLoopback: boolean } = { home: undefined, isLoopback: true } + /** * Register the double as `ctx.remote`, plus one service per scripted * namespace so a plugin injecting `remote.` also unparks. @@ -31,7 +42,7 @@ export class TestRemote { for (const name of Object.keys(namespaces)) { // A namespace named after one of the double's own members would replace // it, and `$mount`'s rejection is the contract a spec relies on. - if (name in TestRemote.prototype || name === 'subscriptions') { + if (name in TestRemote.prototype || name === 'subscriptions' || name === '$host') { throw new TypeError(`TestRemote: scripted namespace "${name}" would shadow the double's own member`) } } diff --git a/packages/test-support/client-runtime/src/settings-remote.ts b/packages/test-support/client-runtime/src/settings-remote.ts index 0a98a8f621..97c2748480 100644 --- a/packages/test-support/client-runtime/src/settings-remote.ts +++ b/packages/test-support/client-runtime/src/settings-remote.ts @@ -63,7 +63,7 @@ export function scriptedSettingsRemote( return Promise.resolve(view === undefined ? { ok: false as const, - error: { code: 'settings-rejected', message: `no scripted namespace "${ns}"`, details: { ns } }, + error: { code: 'settings/rejected', message: `no scripted namespace "${ns}"`, details: { ns } }, } : { ok: true as const, value: view }) } diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index 1cc3e0a372..bb297bb0b6 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -74,7 +74,7 @@ describe('scriptedSettingsRemote', () => { await expect(remote.settings.update('first', {}, undefined)).resolves.toEqual({ ok: true, value: first }) await expect(remote.settings.replace('missing', {}, undefined)).resolves.toMatchObject({ ok: false, - error: { code: 'settings-rejected', details: { ns: 'missing' } }, + error: { code: 'settings/rejected', details: { ns: 'missing' } }, }) await expect(remote.settings.mutate('first', [], undefined)).resolves.toEqual({ ok: true, value: first }) expect(remote.update).toHaveBeenCalledWith('first', {}, undefined) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index f1e2f9a8d1..ec083971dd 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../typert/protocol" + }, { "path": "../../client/connection/tsconfig.client.json" }, diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index ad8d973f2f..055748d6d3 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -5,7 +5,9 @@ */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { RemoteFailure, TypertContextMap } from './types.ts' +import type { TypertContextMap } from './types.ts' + +export { RemoteError, remoteErrorOf } from './remote-error.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ @@ -18,45 +20,12 @@ export function isTypertRemoteSegment(value: string): boolean { return value !== '.' && value !== '..' && TYPERT_REMOTE_SEGMENT_PATTERN.test(value) } -/** - * A lookup policy rejection whose typed payload belongs to the active boundary adapter. - * Gateway adapters preserve this payload instead of collapsing it into an infrastructure failure. - */ -export class TypertLookupFailure extends Error { - /** Adapter-owned failure returned to the caller. */ - readonly failure: Failure - - /** - * Wrap one adapter failure without exposing the rejected identity. - * @param failure - typed failure owned by the active boundary adapter. - */ - constructor(failure: Failure) { - super('Typert lookup policy rejected the requested identity') - this.name = 'TypertLookupFailure' - this.failure = failure - } -} - -/** A business Remote rejection preserved by unary and stream carriers. */ -export class TypertRemoteFailure extends Error { - /** Stable caller-facing failure payload. */ - readonly failure: RemoteFailure - - /** - * Wrap one business rejection for transport without changing its code or details. - * @param failure - business failure returned unchanged to the caller. - */ - constructor(failure: RemoteFailure) { - super(failure.message) - this.name = 'TypertRemoteFailure' - this.failure = failure - } -} - export type { InvocationDescriptor, InvocationParameterDescriptor, InvocationSourceLocation, + RemoteErrorCode, + RemoteErrorDetailsMap, RemoteFailure, RemoteResult, TypertClientEventListener, diff --git a/packages/typert/protocol/src/remote-error.ts b/packages/typert/protocol/src/remote-error.ts new file mode 100644 index 0000000000..ccdd6cd1d8 --- /dev/null +++ b/packages/typert/protocol/src/remote-error.ts @@ -0,0 +1,46 @@ +/** The one Remote failure class shared by owners, the Gateway, and consumers. */ + +import type { RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure } from './types.ts' + +/** + * One Remote call failure: a real Error carrying its stable code and typed + * details. Owners throw it at the failure point; the Host Gateway encodes it + * onto the wire unchanged; the Client face rebuilds an instance for the + * `RemoteResult` error branch, so `throw result.error` keeps throw semantics. + * Discrimination is always by `code`, never by instanceof. + */ +export class RemoteError extends Error { + /** Structural marker: cross-realm/bundle identification never uses instanceof. */ + readonly isDSHRemoteGatewayError: true = true + + /** + * @param code - stable failure code declared in {@link RemoteErrorDetailsMap}. + * @param message - human diagnostic carried across the wire. + * @param details - structured payload typed by the code. + * @param options - standard Error options (`cause` survives in-process only). + */ + constructor( + readonly code: Code, + message: string, + readonly details: RemoteErrorDetailsMap[Code], + options?: ErrorOptions, + ) { + super(message, options) + this.name = 'RemoteError' + } +} + +/** + * Structurally identify a RemoteError thrown across module or realm copies of + * this class. Mechanism-internal: the Gateway and test assertions use it; + * business code receives typed failures and never needs it. + * @param value - a caught value. + * @returns the failure when the marker matches, otherwise undefined. + */ +export function remoteErrorOf(value: unknown): RemoteFailure | undefined { + if (value instanceof Error + && (value as { isDSHRemoteGatewayError?: unknown }).isDSHRemoteGatewayError === true) { + return value as unknown as RemoteFailure + } + return undefined +} diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 8123b2a36a..2911631ce7 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -40,16 +40,30 @@ export interface TypertContextMap {} export interface TypertRemoteMap {} /** - * One Remote call's failure as the carrier reported it. `code` stays open here: - * the closed RPC code union belongs to the carrier package, which already - * depends on this one, so naming it would invert that edge. + * Merge-extensible Remote failure vocabulary: this package declares the + * universal carrier codes once; the Gateway merges its infrastructure codes + * and every owner merges its domain codes next to the throwing code. */ -export interface RemoteFailure { - readonly code: string - readonly message: string - readonly details: object +export interface RemoteErrorDetailsMap { + /** Owner-side business validation refused the request; `issues` carries codec output when one produced it. */ + 'gateway/bad-request': { readonly issues?: readonly object[] } + /** The call was cancelled by the carrier signal or the backend. */ + 'gateway/cancelled': {} + /** Carrier, dispatch, or unclassified Host failure. */ + 'gateway/internal': {} } +/** Every declared Remote failure code. */ +export type RemoteErrorCode = keyof RemoteErrorDetailsMap + +/** + * One Remote call's failure: the code-discriminated union of RemoteError + * instances, so a `code` branch narrows `details` with no cast. + */ +export type RemoteFailure = { + [Code in RemoteErrorCode]: import('./remote-error.ts').RemoteError +}[RemoteErrorCode] + /** * What every generated Remote method resolves to. The Remote face itself folds * carrier failures into the error branch, so no consumer wraps a call to diff --git a/packages/util/time/package.json b/packages/util/time/package.json new file mode 100644 index 0000000000..99b058d735 --- /dev/null +++ b/packages/util/time/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-util-time", + "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", + "version": "0.1.2-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/time" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts new file mode 100644 index 0000000000..efa91dfb30 --- /dev/null +++ b/packages/util/time/src/index.ts @@ -0,0 +1,33 @@ +/** + * Time vocabulary shared by the wire boundaries that accept a caller's zone. + * Validation and canonicalization only: this library formats nothing and owns + * no failure vocabulary — each boundary declares and throws its own refusal. + * @module @deepseek-ai/dsh-time + */ + +/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ +const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ + +/** + * Validate and canonicalize one caller-supplied IANA zone at a wire boundary. + * + * The canonical name is what a later reader needs: a zone identity is stored on + * durable records and resolved again by another process, so an alias accepted + * here would not compare equal to the zone a reader derives. + * @param value - the caller's reported zone name. + * @returns the canonical zone, or `undefined` when the name is unusable. + */ +export function canonicalClientTimeZone(value: string): string | undefined { + if (value.length === 0 || value.trim() !== value + || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined + try { + const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) + .resolvedOptions().timeZone + /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ + if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined + return canonical + } catch { + // Intl rejects unsupported zone names; the caller maps that parser rejection. + return undefined + } +} diff --git a/packages/util/time/src/invariant.ts b/packages/util/time/src/invariant.ts new file mode 100644 index 0000000000..2a52f0ffb3 --- /dev/null +++ b/packages/util/time/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-time`. + * @module @deepseek-ai/dsh-time/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-util-time' + +/** Cordis companion plugin name. */ +export const name = 'time-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its + * zone-canonicalization algebra is enforced by unit tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/util/time/tests/time.spec.ts b/packages/util/time/tests/time.spec.ts new file mode 100644 index 0000000000..905c555700 --- /dev/null +++ b/packages/util/time/tests/time.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' + +describe('canonicalClientTimeZone', () => { + it('accepts UTC and Area/Location names unchanged', () => { + expect(canonicalClientTimeZone('UTC')).toBe('UTC') + expect(canonicalClientTimeZone('Asia/Shanghai')).toBe('Asia/Shanghai') + expect(canonicalClientTimeZone('Europe/London')).toBe('Europe/London') + }) + + it('answers the platform-canonical name rather than the alias asked for', () => { + // A durable record is compared against the zone a later reader derives, so + // an alias must not survive the boundary. Which name each alias group + // resolves to is the runtime's ICU data, not this library's choice. + const canonical = canonicalClientTimeZone('Asia/Chongqing') + expect(canonical).not.toBe('Asia/Chongqing') + expect(canonicalClientTimeZone(canonical ?? '')).toBe(canonical) + }) + + it('refuses blank, padded, abbreviated, and single-segment names', () => { + for (const value of ['', ' ', ' UTC', 'UTC ', 'CST', 'GMT+8', 'Asia', 'utc']) { + expect(canonicalClientTimeZone(value)).toBeUndefined() + } + }) + + it('refuses a well-formed name the platform does not support', () => { + expect(canonicalClientTimeZone('Not/A_Real_Zone')).toBeUndefined() + }) +}) diff --git a/packages/util/time/tsconfig.json b/packages/util/time/tsconfig.json new file mode 100644 index 0000000000..779effc3cc --- /dev/null +++ b/packages/util/time/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index c3126d9aa7..21cd0a96e8 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index fcdbf049bf..4eee465961 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -7,6 +7,7 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-typert-protocol' /** * Identifies one workspace record. A generated uuid, never the path: path @@ -14,6 +15,13 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' */ export type WorkspaceId = Branded<'WorkspaceId'> +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No registration carries that Workspace identity. */ + 'workspace/not-found': { readonly workspaceId: WorkspaceId } + } +} + /** * One workspace: a stable id over an existing directory, a display title, and * an ordered candidate account of sessions. Membership requires both an id in diff --git a/packages/workspace/workspace/tsconfig.json b/packages/workspace/workspace/tsconfig.json index eb18440d55..d007df1796 100644 --- a/packages/workspace/workspace/tsconfig.json +++ b/packages/workspace/workspace/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../session/session-persistence" }, + { + "path": "../../typert/protocol" + }, { "path": "../../util/brand" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e582ee5fd..d607e39d27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -837,6 +837,9 @@ importers: '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto + '@deepseek-ai/dsh-util-time': + specifier: workspace:^ + version: link:../../util/time '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path @@ -2911,6 +2914,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger @@ -3063,9 +3069,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store @@ -3103,9 +3106,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3239,9 +3239,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -5151,9 +5148,6 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../../client/connection '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules @@ -8502,6 +8496,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -8559,6 +8556,9 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval + '@deepseek-ai/dsh-util-time': + specifier: workspace:^ + version: link:../../util/time packages/subagent/subagent-acp: dependencies: @@ -9403,6 +9403,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -9716,6 +9719,15 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/util/time: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/util/timeout: devDependencies: '@deepseek-ai/cordis': @@ -10173,6 +10185,9 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol python/sdk-runtime: dependencies: diff --git a/tsconfig.base.json b/tsconfig.base.json index 759b985d1b..e18c1b6a26 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -235,6 +235,10 @@ "@deepseek-ai/dsh-experimental-inspector": ["./packages/experimental/inspector/src"], "@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"], "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"], + // util/ folders are role-named without the util- prefix their npm names carry, + // so this pair stays hand-written like dsh-util-crypto above. + "@deepseek-ai/dsh-util-time": ["./packages/util/time/src"], + "@deepseek-ai/dsh-util-time/invariant": ["./packages/util/time/src/invariant.ts"], // BEGIN generated package aliases — pnpm run gen-tsconfig-paths "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], "@deepseek-ai/dsh-acp/invariant": ["./packages/acp/acp/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 561cee9b17..1849fce913 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -130,6 +130,7 @@ { "path": "./packages/util/launch-environment" }, { "path": "./packages/util/native-command" }, { "path": "./packages/util/home-paths" }, + { "path": "./packages/util/time" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/crypto" }, { "path": "./packages/util/workspace-path" },