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 01/12] 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" }, From d40d678b93489c4d906f25cb2049bc3b9ffa7b0d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:44:23 +0800 Subject: [PATCH 02/12] fix(api): repair runtime closure, gateway client bundle, and $host coverage - python/sdk-runtime: add @deepseek-ai/dsh-util-time so the runtime dependency closure stays closed (dsh-subagent now depends on it). - api/gateway: drop the dsh.client.external request for dsh-typert-protocol and admit the protocol package into INLINE_SAFE instead. The loader module table has no supplier for the protocol package, so the built client factory threw at require time; the protocol layer is duplication-safe by design (string marker, code-based discrimination), which is the inline-safe admission criterion. - api/gateway tests: cover the $host getter (live service read, pre-ready home, and the construction-time fallback after the Connection service is withdrawn). --- packages/api/gateway/package.json | 3 -- .../api/gateway/tests/gateway.client.spec.ts | 30 +++++++++++++++++++ packages/client/tsdown.client.ts | 2 +- pnpm-lock.yaml | 3 ++ python/sdk-runtime/package.json | 1 + 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index e961defbad..d5601ab188 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -35,9 +35,6 @@ }, "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/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 519b4dc0cc..829afc6c2f 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' import { apply as applyConnection, + type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionHandle, } from '@deepseek-ai/dsh-client-connection/client' @@ -548,6 +549,35 @@ describe('Client Remote transport readiness', () => { await client.dispose() }) + it('reports Host facts as plain reads and keeps them through Connection withdrawal', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const generation = new GenerationHarness() + const live: { snapshot: ConnectionGeneration | undefined } = { snapshot: undefined } + const handle = { + isLoopback: true, + generation: { getSnapshot: () => live.snapshot, subscribe: () => () => {} }, + rpc: { + call: vi.fn(), + open: () => unexpectedInProcessStream(), + }, + registerGenerationSource: generation.register, + start: () => ({ stop: () => {} }), + } as unknown as ConnectionHandle + const withdraw = ctx.provide('connection', handle) + const client = ctx.plugin({ inject, apply }) + await client + const remote = ctx.remote + + expect(remote.$host).toEqual({ home: undefined, isLoopback: true }) + live.snapshot = { id: 1, host: { home: '/hosts/primary' } } + expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + + withdraw() + expect(ctx.get('connection')).toBeUndefined() + expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + }) + it('starts after Loader settlement and stops the owned loop on disposal', async () => { const readiness = deferredReadiness() const { client, start, stop } = await loaderReadinessBench(readiness.promise) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 984a6a6828..f31d1510e3 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|typert-protocol|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d607e39d27..9b37eee6d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10539,6 +10539,9 @@ importers: '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../packages/interaction/user-questions + '@deepseek-ai/dsh-util-time': + specifier: workspace:^ + version: link:../../packages/util/time '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../../packages/web/web diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 331d388a0f..61dc113188 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -119,6 +119,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", + "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-http": "workspace:^", "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", From 5af9eec51c3d0612f2583670dd13407534bc5a9f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:56:24 +0800 Subject: [PATCH 03/12] fix(api): address review findings on the gateway client failure face - classify a carrier throw under a caller-aborted signal as gateway/cancelled instead of gateway/internal, matching the code the Host produces when the abort wins the wire round-trip. - read $host facts from the construction-time Connection handle, matching $stream; the service cannot be replaced without restarting this plugin, so the live re-lookup was dead complexity. - state rebuiltFailure's actual contract in its comment: codes pass through verbatim without runtime validation. - correct the @module name in dsh-util-time. --- packages/api/gateway/src/client/index.ts | 22 ++++++++---- .../api/gateway/tests/gateway.client.spec.ts | 36 +++++++++++++++++++ packages/util/time/src/index.ts | 2 +- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index e52bcd0f31..303f311d6a 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -183,10 +183,9 @@ class ClientRemoteService extends Service implements ClientRemote { } get $host(): RemoteHostFacts { - const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined ?? this.connection return { - home: connection.generation.getSnapshot()?.host.home, - isLoopback: connection.isLoopback, + home: this.connection.generation.getSnapshot()?.host.home, + isLoopback: this.connection.isLoopback, } } @@ -438,7 +437,10 @@ class ClientRemoteService extends Service implements ClientRemote { return { ok: true, value: result.value } } catch (error) { // Carrier throws (offline or abort) are outcomes of the call, not assembly - // faults, so they join the same error branch. + // faults, so they join the same error branch. A caller-aborted call is a + // cancellation even when the local throw wins the race against the wire + // round-trip, so it gets the same code the Host would have produced. + if (prepared.signal.aborted) return cancelledFailure(endpoint, error) return carrierFailure(endpoint, error) } } @@ -721,6 +723,13 @@ function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { + return { + ok: false, + error: new RemoteError('gateway/cancelled', `client api: Remote invocation "${endpoint}" was aborted`, {}, { cause }), + } +} + function internalFailure(message: string): Extract, { readonly ok: false }> { return { ok: false, error: new RemoteError('gateway/internal', message, {}) } } @@ -738,8 +747,9 @@ export function isRemoteFailure(error: unknown): error is RemoteFailure { /** * 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. + * carries a real Error and `throw result.error` keeps throw semantics. The code + * is passed through verbatim without runtime validation: a code outside this + * Client's merged map still surfaces as-is, so a newer Host stays readable. */ function rebuiltFailure(error: { code: string; message: string; details: object }): RemoteFailure { return new RemoteError(error.code as never, error.message, error.details as never) diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 829afc6c2f..b6a1d12414 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1305,6 +1305,42 @@ describe('Client Typert API', () => { }) }) + it('classifies a carrier throw under a caller-aborted signal as gateway/cancelled', async () => { + const controller = new AbortController() + const ctx = await bench(vi.fn().mockImplementation(async () => { + controller.abort() + throw new Error('carrier aborted mid-flight') + })) + await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) + + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' }, controller.signal)) + .resolves.toMatchObject({ + ok: false, + error: { + code: 'gateway/cancelled', + message: 'client api: Remote invocation "probe/create" was aborted', + details: {}, + }, + }) + }) + + it('keeps a carrier throw under an unaborted caller signal in the internal branch', async () => { + const controller = new AbortController() + const ctx = await bench(vi.fn() + .mockRejectedValue(new Error('carrier offline'))) + await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) + + await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' }, controller.signal)) + .resolves.toMatchObject({ + ok: false, + error: { + code: 'gateway/internal', + message: 'client api: probe/create failed: carrier offline', + details: {}, + }, + }) + }) + it('owns each $on subscription in the calling fiber', async () => { const { ctx, client, carrier } = await eventBench() const seen: string[] = [] diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts index efa91dfb30..1867965cf2 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -2,7 +2,7 @@ * 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 + * @module @deepseek-ai/dsh-util-time */ /** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ From 2f2e6d627b28a1c36001fea2bc0a2124c4f3c5f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:09:55 +0800 Subject: [PATCH 04/12] fix(api): resolve review findings on stream boundary, inject staleness, and ctx discipline - api/gateway: mark terminal Remote-stream escapes (carrier retry exhaustion and pre-acceptance end classification) as RemoteError('gateway/internal') at the two escape points; marked failures pass through verbatim. The carrier class stays the retry-internal signal for carrierFailed and the ended(true) retry trigger. Regression coverage lands on the session and workspace stream consumers. - client/ui-tool, client/ui-workspace: read $host.home through a hooks observable subscribed to connection/reset; the slot renderer memoizes inject results per entry, so the previous plain-value injection froze home at the first render. - client/ui-settings-models: components no longer receive ctx; apply binds the credential and settings Remote operations into callbacks, and the settings/conflict code judgment stays in the apply world. --- .../api/gateway/src/client/remote-stream.ts | 21 +++- .../tests/control-retry.client.spec.ts | 16 ++- .../tests/session.client.spec.ts | 20 +++- .../tests/transport.client.spec.ts | 21 ++++ .../src/client/CustomProviderCard.tsx | 22 ++-- .../src/client/DeepSeekOnboardingDialog.tsx | 10 +- .../src/client/ModelListEditor.tsx | 16 +-- .../src/client/ModelsSection.tsx | 38 +++--- .../src/client/ProviderEditor.tsx | 41 ++++--- .../ui-settings-models/src/client/index.ts | 9 +- .../src/client/operations.ts | 109 ++++++++++++++++++ .../tests/apply.client.spec.ts | 4 +- .../tests/components.client.spec.tsx | 42 ++++--- .../tests/onboarding-dialog.client.spec.tsx | 4 +- .../tests/provider-form.client.spec.tsx | 30 +++-- packages/client/ui-tool/src/client/apply.ts | 10 +- .../ui-tool/src/client/contract/slots.ts | 15 ++- .../ui-tool/src/client/tool/ToolCallTree.tsx | 3 +- .../ui-tool/src/client/tool/ToolDetails.tsx | 5 +- .../tests/tool-call-tree.client.spec.tsx | 2 +- .../tests/tool-details-render.client.tsx | 2 +- .../ui-workspace/src/client/contract/slots.ts | 12 +- .../client/ui-workspace/src/client/index.ts | 10 +- .../src/client/rows/WorkspaceBrowser.tsx | 3 +- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../tests/host-home-staleness.client.spec.tsx | 91 +++++++++++++++ .../tests/workspace-browser.client.spec.tsx | 4 +- 27 files changed, 451 insertions(+), 111 deletions(-) create mode 100644 packages/client/ui-settings-models/src/client/operations.ts create mode 100644 packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts index 71f1f546ae..a2b018ae91 100644 --- a/packages/api/gateway/src/client/remote-stream.ts +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -1,5 +1,6 @@ /** Reconnecting lifecycle for one single-consumer Remote stream. */ +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { RemoteStreamCarrierError } from './stream-client.ts' @@ -128,7 +129,7 @@ export class RemoteStream implements AsyncIterable> } catch (error) { if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue - if (!(error instanceof RemoteStreamCarrierError)) throw error + if (!(error instanceof RemoteStreamCarrierError)) throw terminalStreamFailure(error) this.options.carrierFailed?.(error) if (revision !== this.revision) continue attempt++ @@ -137,7 +138,7 @@ export class RemoteStream implements AsyncIterable> } catch (retryError) { if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue - throw retryError + throw terminalStreamFailure(retryError) } } finally { this.generationAbort = undefined @@ -195,6 +196,22 @@ async function waitForRemoteStreamRetry( }) } +/** + * Mark a terminal escape before it crosses the stream boundary: consumers + * discriminate failures by code, so an unmarked throw reads as a local bug. + * Marked failures pass through verbatim. The carrier class never escapes as a + * terminal outcome — it stays the retry-internal signal fed to `carrierFailed` + * and the `ended(true)` retry trigger. + */ +function terminalStreamFailure(error: unknown): Error { + return remoteErrorOf(error) ?? new RemoteError( + 'gateway/internal', + error instanceof Error ? error.message : String(error), + {}, + { cause: error }, + ) +} + function isAborted(signal: AbortSignal): boolean { return signal.aborted } diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index 234251babb..7e127fbff0 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteStreamCarrierError, @@ -106,11 +107,24 @@ describe('RemoteStream', () => { { terminal: repeated }, ], carrierFailed) - await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(repeated) + await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + isDSHRemoteGatewayError: true, + code: 'gateway/internal', + message: 'isolated retry failed', + details: {}, + cause: repeated, + }) expect(carrierFailed).toHaveBeenNthCalledWith(1, first) expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated) }) + it('passes a marked Remote failure through the terminal boundary verbatim', async () => { + const failure = new RemoteError('gateway/internal', 'host stream failed', {}) + const stream = supervisor(hostSource(true).connection, [{ terminal: failure }]) + + await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(failure) + }) + it('waits for a replacement Host generation after observing unavailability', async () => { let available = false let listener: (() => void) | undefined diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index f59ed3c287..565dcf99bf 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session, type SessionOptions } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' @@ -85,10 +86,25 @@ describe('Session open', () => { expect(snapshot.openError?.code).toBe('session/not-found') }) - it('propagates a non-Remote throw raised while opening', async () => { + it('lands exhausted carrier retries in openState=error as gateway/internal', async () => { + const { api, session } = makeSession() + // Two consecutive carrier losses before any opening is accepted exhaust the + // Gateway's retry budget; the escaping failure crosses the stream boundary marked. + api.onHistory = () => Promise.reject(new RemoteStreamCarrierError('history carrier down')) + await session.open() + expect(session.getSnapshot().openState).toBe('error') + expect(session.getSnapshot().openError).toMatchObject({ + code: 'gateway/internal', message: 'history carrier down', + }) + expect(api.followStarts).toHaveLength(2) + }) + + it('lands a Gateway-marked stream failure in openState=error', async () => { const { api, session } = makeSession() api.onHistory = () => Promise.reject(new Error('socket died')) - await expect(session.open()).rejects.toThrow('socket died') + await session.open() + expect(session.getSnapshot().openState).toBe('error') + expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'socket died' }) }) it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 52d7a21020..fdfbf23105 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -231,6 +231,27 @@ describe('Workspace Controller Client apply', () => { expect(ctx.get('workspaces')).toBeUndefined() }) + it('publishes exhausted carrier retries as a gateway/internal error state', async () => { + const ctx = new Context() + // Neither generation reaches an accepted baseline, so the retry budget runs + // out and the escaping carrier failure crosses the stream boundary marked. + const remote = new ScriptedWorkspaceRemote([ + { frames: [], error: new RemoteStreamCarrierError('generation lost') }, + { frames: [], error: new RemoteStreamCarrierError('generation lost again') }, + ]) + provideClientServices(ctx, remote) + const fiber = ctx.plugin(WorkspaceClientPlugin) + await fiber + await waitFor(() => { + expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + state: 'error', + error: { code: 'gateway/internal', message: 'generation lost again' }, + }) + }) + expect(remote.calls).toBe(2) + await fiber.dispose() + }) + it('marks carrier loss while retrying and publishes a later protocol failure', async () => { const ctx = new Context() const remote = new ScriptedWorkspaceRemote([ diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index de1c20b8bf..b5034b12b4 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -23,7 +23,6 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client' import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' @@ -31,6 +30,7 @@ import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' import { deriveKeyRef } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -59,8 +59,8 @@ export interface CustomProviderCardProps { * than a silent overwrite of its whole profile. */ revision: number - /** The page plugin's context, whose Remote namespaces carry the write and the endpoint interrogation. */ - ctx: ClientContext + /** The Host operations this card writes and interrogates through. */ + operations: ModelsOperations /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -75,7 +75,7 @@ export interface CustomProviderCardProps { * @returns the creation card. */ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { - const { taken, protocols, ctx, t } = props + const { taken, protocols, operations, t } = props // The write is checked against the revision on which this draft was opened. const [openedAt] = useState(() => props.revision) const [route, setRoute] = useState('') @@ -147,15 +147,13 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // `taken` is a snapshot too, so the id check alone cannot see a route // declared after this card opened; the revision makes that race a // `settings-conflict` instead of a write over the other profile. - const response = await ctx.remote.settings.mutate( + const written = await operations.writeSettings( NS, [{ op: 'set', path: ['providers', route], value: profile as JsonValue }], openedAt, ) - if (!response.ok) { - return response.error.code === 'settings/conflict' - ? t('conflict') - : response.error.message + if (written.kind !== 'written') { + return written.kind === 'conflict' ? t('conflict') : written.message } // The provider now exists. A retry after the key write below fails must // not re-run this mutate: the revision it holds is the one this write @@ -164,10 +162,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { setCommitted(true) } if (storesKey) { - const stored = await ctx.remote.credentials.set(keyRef, keyValue) + const stored = await operations.storeCredential(keyRef, keyValue) // The profile landed; saying the key did not is the only honest report, // and the retry above now goes straight back to this write. - if (!stored.ok) return stored.error.message + if (stored !== undefined) return stored } return undefined } @@ -274,7 +272,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { ...keyValue.length === 0 ? {} : { apiKey: keyValue }, }} probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure} - ctx={ctx} + operations={operations} t={t} disabled={profileDisabled} /> diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 546f89e4a0..3d764a1ed6 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -8,11 +8,11 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' import { onboardingReadiness } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -27,8 +27,8 @@ export interface DeepSeekOnboardingInjected { } /** Shared Models-page join controller. */ controller: ModelsSettingsStore - /** The plugin context the reused Models credential editor writes through. */ - ctx: ClientContext + /** The Host operations the reused Models credential editor writes through. */ + operations: ModelsOperations /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Feature copy. */ @@ -51,7 +51,7 @@ function assertNever(_value: never): never { * @returns the onboarding modal or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { - const { complete, controller, useModels, ctx, schema, t } = props + const { complete, controller, useModels, operations, schema, t } = props const state = useModels(snapshot => snapshot) const readiness = onboardingReadiness(state) @@ -106,7 +106,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): namespace={namespace} schema={schema} settingsPath={row.entry.settingsPath} - ctx={ctx} + operations={operations} t={t} readOnly={false} hideTitle diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 69714486c2..a81dde113d 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -16,10 +16,10 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-api-remotes/client' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx' +import type { ModelsOperations } from './operations.ts' import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -79,8 +79,8 @@ export interface ModelListEditorProps { * told what the field already says. */ probeBlocked?: keyof typeof en | undefined - /** The page plugin's context, whose `remote.llm` namespace answers the fetch action. */ - ctx: ClientContext + /** The Host operations whose interrogation answers the fetch action. */ + operations: ModelsOperations /** Section copy. */ t: (key: keyof typeof en) => string /** Disable every control (read-only deployment or a pending write). */ @@ -157,7 +157,7 @@ function adopt(candidate: LlmDiscoveredModel): ModelDraft { * @returns the model-list editor. */ export function ModelListEditor(props: ModelListEditorProps): ReactNode { - const { models, onChange, probe, ctx, t, disabled } = props + const { models, onChange, probe, operations, t, disabled } = props const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) const [candidates, setCandidates] = useState(undefined) @@ -229,17 +229,17 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { setBusy(true) setFailure(undefined) try { - const response = await ctx.remote.llm.discoverModels(probe.settingsNs, { + const answer = await operations.discoverModels(probe.settingsNs, { ...probe.provider === undefined ? {} : { provider: probe.provider }, ...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL }, ...probe.api === undefined ? {} : { api: probe.api }, ...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey }, }) - if (!response.ok) { - setFailure(response.error.message) + if (answer.kind === 'refused') { + setFailure(answer.message) return } - const found = response.value + const found = answer.models if (found.length === 0) { setFailure(t('fetchEmpty')) return diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 4611067f3e..b99767a364 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -14,7 +14,6 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls this package's SlotMap merge (the two Models child slots). @@ -22,6 +21,7 @@ import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' import { deriveKeyRef, protocolChoices, providerUsable } from './store.ts' import type { ModelsSettingsStore, ProviderRow } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -35,8 +35,8 @@ export interface ModelsSectionInjected { /** Page snapshot bound by the UI renderer as useSnapshot. */ snapshot: ModelsSettingsStore['store'] } - /** The page plugin's context, whose Remote namespaces the editors write through. */ - ctx: ClientContext + /** The Host operations the section and its cards invoke. */ + operations: ModelsOperations /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Section copy. */ @@ -81,7 +81,7 @@ interface EditorTarget extends ProviderIdentity { /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, - 'namespace' | 'schema' | 'ctx' | 't' | 'readOnly' | 'onClose' + 'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose' > { target: EditorTarget } @@ -105,26 +105,26 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): * and the whole operation safely retryable; both unsets are idempotent. * The settings removal names the profile rather than rebuilding its whole * namespace from a partial view. - * @param ctx - the page plugin's context, carrying the settings and credential Remote namespaces. + * @param operations - the page's Host operations. * @param controller - the page store to refresh. * @param target - the provider's settings address and optional managed credential. * @returns the failure message, or undefined once the write and reload landed. */ export async function removeProviderProfile( - ctx: ClientContext, + operations: ModelsOperations, controller: ModelsSettingsStore, target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise { if (target.credentialRef !== undefined) { - const credential = await ctx.remote.credentials.unset(target.credentialRef) - if (!credential.ok) return credential.error.message + const credential = await operations.removeCredential(target.credentialRef) + if (credential !== undefined) return credential } - const response = await ctx.remote.settings.mutate( + const written = await operations.writeSettings( target.settingsNs, [{ op: 'unset', path: [...target.settingsPath] }], undefined, ) - if (!response.ok) return response.error.message + if (written.kind !== 'written') return written.message await controller.load() return undefined } @@ -193,16 +193,16 @@ export function providerCopy(template: string, target: ProviderIdentity): string * @returns the section, or null while the shell has not injected yet. */ export function ModelsSection(props: ModelsSectionProps): ReactNode { - const { controller, useSnapshot, ctx, schema, t, renderSlot } = props + const { controller, useSnapshot, operations, schema, t, renderSlot } = props if ( - controller === undefined || useSnapshot === undefined || ctx === undefined + controller === undefined || useSnapshot === undefined || operations === undefined || schema === undefined || t === undefined ) return null - return + return } function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode { - const { controller, ctx, schema, t } = injected + const { controller, operations, schema, t } = injected const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) @@ -250,7 +250,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS if (deleteTarget === undefined || deleting) return setDeleting(true) setDeleteFailure(undefined) - void removeProviderProfile(ctx, controller, deleteTarget) + void removeProviderProfile(operations, controller, deleteTarget) .then((failure) => { if (failure !== undefined) { setDeleteFailure(failure) @@ -331,7 +331,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - ctx, + operations, t, readOnly: !state.writable, onClose: (changed) => { closeSetup(changed, target) }, @@ -426,7 +426,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - ctx, + operations, t, readOnly: !state.writable, onClose: (changed) => { closeEditor(changed, target) }, @@ -466,7 +466,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS namespace={addNamespace} schema={schema} settingsPath={addTarget.settingsPath} - ctx={ctx} + operations={operations} t={t} readOnly={!state.writable} onClose={(changed) => { closeEditor(changed, addTarget) }} @@ -488,7 +488,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS protocols={protocols} /* v8 ignore next -- the card only opens from a button disabled without this namespace */ revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0} - ctx={ctx} + operations={operations} t={t} readOnly={!state.writable} onClose={(changed) => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 3c4cea5aab..b4664ce40f 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -23,7 +23,6 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { CredentialInfo, JsonValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -34,6 +33,7 @@ import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import { deriveKeyRef, protocolChoices } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -66,8 +66,8 @@ export interface ProviderEditorProps { schema: SettingsSchemaOperations /** Path from the section root to this provider's profile. */ settingsPath: readonly string[] - /** The page plugin's context, whose Remote namespaces carry the writes and the endpoint interrogation. */ - ctx: ClientContext + /** The Host operations this card writes and interrogates through. */ + operations: ModelsOperations /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -155,7 +155,7 @@ function refFor( * @returns the editor card. */ export function ProviderEditor(props: ProviderEditorProps): ReactNode { - const { namespace, schema, settingsPath, ctx, t } = props + const { namespace, schema, settingsPath, operations, t } = props const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') const [keyState, setKeyState] = useState(undefined) @@ -188,12 +188,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { setKeyState(undefined) // The key state is a placeholder hint, not a precondition for editing: a // refused describe leaves the card without the "already configured" hint. - void ctx.remote.credentials.describe([keyRef]).then((response) => { - if (stale || !response.ok) return - setKeyState(response.value[keyRef]) + void operations.describeCredential(keyRef).then((described) => { + if (stale) return + setKeyState(described) }) return () => { stale = true } - }, [ctx, keyRef]) + }, [operations, keyRef]) const stringAt = (source: unknown, key: string): string | undefined => { const value = schema.getPath(source, [key]) @@ -277,19 +277,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ? [{ op: 'set', path: [...settingsPath], value: {} }] : pathOps(settingsPath, committedOriginal, next) if (ops.length > 0) { - const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision) - if (!response.ok) { - return response.error.code === 'settings/conflict' - ? t('conflict') - : response.error.message - } - setCommittedOriginal(schema.getPath(response.value.user, settingsPath)) - setExpectedRevision(response.value.revision) + const written = await operations.writeSettings(ns, ops, expectedRevision) + if (written.kind !== 'written') return written.kind === 'conflict' ? t('conflict') : written.message + setCommittedOriginal(schema.getPath(written.view.user, settingsPath)) + setExpectedRevision(written.view.revision) setDraft(next) } if (keyValue.length > 0) { - const stored = await ctx.remote.credentials.set(keyRef, keyValue) - if (!stored.ok) return stored.error.message + const stored = await operations.storeCredential(keyRef, keyValue) + if (stored !== undefined) return stored } setKeyDraft('') return undefined @@ -464,7 +460,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} /> ) - : } + : ( + + )}
} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 8cb361ead6..721f807092 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -23,6 +23,7 @@ import { WelcomeNotice } from './WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts' import { ModelsSettingsStore } from './store.ts' +import { createModelsOperations } from './operations.ts' import { createSettingsSchemaOperations } from './schema-operations.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' @@ -43,6 +44,7 @@ const NS = 'settings.models' export type { ModelsSettingsState, ProviderDirectoryEntry, ProviderRow, } from './store.ts' +export type { ModelDiscoveryOutcome, ModelsOperations, SettingsWriteOutcome } from './operations.ts' /** * Refetch the page snapshot only after its first load: an unopened Models @@ -74,6 +76,9 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') const schema = createSettingsSchemaOperations(ctx.settingsSchema) + // Bound once here, where the Remote namespaces are declared in this plugin's + // own `inject`; the cards receive callbacks and never a context. + const operations = createModelsOperations(ctx) const controller = new ModelsSettingsStore(ctx, schema, ctx.settingsScope.describe()) // Registration-time text (the nav label thunk) and the inject faces share // one bound translate; copy freshness rides the locale revision. @@ -81,14 +86,14 @@ export function apply(ctx: ClientContext): void { const injected = (): ModelsSectionInjected => ({ controller, hooks: { snapshot: controller.store }, - ctx, + operations, schema, t, }) const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({ controller, hooks: { models: controller.store }, - ctx, + operations, schema, t, }) diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts new file mode 100644 index 0000000000..8ca6beb798 --- /dev/null +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -0,0 +1,109 @@ +/** + * The Host reads and writes the Models cards perform, as callbacks built in the + * plugin body. Cards receive these instead of a context: the outcomes name what + * a card renders — a stored view, a stale revision, a refusal message — so the + * failure codes and Remote namespaces stay in the apply world. + */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { + CredentialInfo, LlmDiscoveredModel, LlmModelDiscoveryRequest, + SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-api-remotes/client' + +/** What one namespace write answered. */ +export type SettingsWriteOutcome = + /** Committed; the view carries the stored user subtree and the new revision. */ + | { readonly kind: 'written'; readonly view: SettingsNamespaceView } + /** + * The stored revision moved after the card read it, so the draft is stale. + * The message stays for callers that report the Host diagnostic as it is. + */ + | { readonly kind: 'conflict'; readonly message: string } + /** Any other refusal, with the Host's own diagnostic. */ + | { readonly kind: 'refused'; readonly message: string } + +/** What one endpoint interrogation answered. */ +export type ModelDiscoveryOutcome = + /** The candidates the provider disclosed, in its own order. */ + | { readonly kind: 'found'; readonly models: readonly LlmDiscoveredModel[] } + /** The interrogation was refused, with the Host's own diagnostic. */ + | { readonly kind: 'refused'; readonly message: string } + +/** The Host operations the Models page and its cards invoke. */ +export interface ModelsOperations { + /** + * Read one credential reference's state. + * @param ref - credential reference name. + * @returns the state, or undefined when the reference is unknown or the read was refused. + */ + describeCredential(ref: string): Promise + /** + * Store one credential literal under its reference. + * @param ref - credential reference name. + * @param value - the literal to store. + * @returns the refusal message, or undefined once stored. + */ + storeCredential(ref: string, value: string): Promise + /** + * Remove one credential reference (idempotent). + * @param ref - credential reference name. + * @returns the refusal message, or undefined once removed. + */ + removeCredential(ref: string): Promise + /** + * Apply path operations to one settings namespace. + * @param ns - settings namespace identity. + * @param ops - ordered path operations against the stored section, as the + * wire takes them (the Remote signature owns the array). + * @param expectedRevision - revision the draft was opened at, or undefined to write unfenced. + * @returns the write outcome the card renders from. + */ + writeSettings( + ns: string, + ops: SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise + /** + * Ask a provider endpoint what models it serves. + * @param settingsNs - namespace whose adapter family answers. + * @param request - endpoint facts as the form currently shows them. + * @returns the candidates, or the refusal. + */ + discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise +} + +/** + * Bind the page's Host operations to the plugin's own Remote namespaces. + * @param ctx - the page plugin's context, which declares `remote.credentials`, + * `remote.llm`, and `remote.settings` in its own `inject`. + * @returns the callbacks the section and its cards are injected with. + */ +export function createModelsOperations(ctx: ClientContext): ModelsOperations { + return { + describeCredential: async (ref) => { + const response = await ctx.remote.credentials.describe([ref]) + return response.ok ? response.value[ref] : undefined + }, + storeCredential: async (ref, value) => { + const response = await ctx.remote.credentials.set(ref, value) + return response.ok ? undefined : response.error.message + }, + removeCredential: async (ref) => { + const response = await ctx.remote.credentials.unset(ref) + return response.ok ? undefined : response.error.message + }, + writeSettings: async (ns, ops, expectedRevision) => { + const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision) + if (response.ok) return { kind: 'written', view: response.value } + const { code, message } = response.error + return code === 'settings/conflict' ? { kind: 'conflict', message } : { kind: 'refused', message } + }, + discoverModels: async (settingsNs, request) => { + const response = await ctx.remote.llm.discoverModels(settingsNs, request) + return response.ok + ? { kind: 'found', models: response.value } + : { kind: 'refused', message: response.error.message } + }, + } +} 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 1d42d47682..aca0e3bfb7 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -85,7 +85,7 @@ describe('ui-settings-models apply', () => { expect(injected.t('deleteTitle')).toBe('删除 {provider}?') expect(typeof injected.controller.load).toBe('function') expect(injected.hooks.snapshot).toBe(injected.controller.store) - expect(injected.ctx).toBeDefined() + expect(typeof injected.operations.writeSettings).toBe('function') const onboarding = before.slots.entries('settings.onboarding') expect(onboarding).toHaveLength(2) expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({ @@ -99,7 +99,7 @@ describe('ui-settings-models apply', () => { deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected )() expect(deepSeekInjected.hooks.models).toBe(injected.controller.store) - expect(deepSeekInjected.ctx).toBeDefined() + expect(typeof deepSeekInjected.operations.storeCredential).toBe('function') const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() 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 361e080e32..64b920a81a 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -18,6 +18,8 @@ import { import { apiKeyFailure } from '../src/client/apiKey.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' +import { createModelsOperations } from '../src/client/operations.ts' +import type { ModelsOperations } from '../src/client/operations.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -220,6 +222,20 @@ function ctxWith(face: object): PageContext { return ctx } +/** + * The cards' injected Host operations over the same script, bound once per face + * as the plugin body binds them: an editor effect keyed by this face would + * otherwise re-probe on every render. + */ +const operations = new WeakMap() +function operationsWith(face: object): ModelsOperations { + const existing = operations.get(face) + if (existing !== undefined) return existing + const bound = createModelsOperations(ctxWith(face)) + operations.set(face, bound) + return bound +} + /** One recorded child-slot dispatch: seat name, owner share, kind options. */ type RenderSlotCall = [name: string, owner: Record, opts?: { entryKey?: string }] @@ -252,7 +268,7 @@ async function mountFace(scripted: ReturnType) { const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - ctx, + operations: operationsWith(face), schema: settingsSchema, t, renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'], @@ -387,7 +403,7 @@ describe('ModelsSection', () => { render( null} @@ -412,7 +428,7 @@ describe('ModelsSection', () => { render( null} @@ -494,7 +510,7 @@ describe('ModelsSection', () => { namespace={wireNamespaces()[0]!} schema={settingsSchema} settingsPath={[]} - ctx={ctxWith(face)} + operations={operationsWith(face)} t={t} readOnly={false} credentialOnly @@ -747,7 +763,7 @@ describe('ModelsSection', () => { namespace={overridden} schema={settingsSchema} settingsPath={[]} - ctx={ctxWith(face)} + operations={operationsWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -977,7 +993,7 @@ describe('ModelsSection', () => { namespace={bare} schema={settingsSchema} settingsPath={[]} - ctx={ctxWith(face)} + operations={operationsWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -1138,7 +1154,7 @@ describe('ModelsSection', () => { render( null} @@ -1272,7 +1288,7 @@ describe('ModelsSection', () => { render( null} @@ -1295,7 +1311,7 @@ describe('ModelsSection', () => { render( null} @@ -1357,7 +1373,7 @@ describe('ModelsSection', () => { render( null} @@ -1370,7 +1386,7 @@ describe('ModelsSection', () => { // would widen the write for no benefit. const { face, mutate, controller } = await mountSection() await removeProviderProfile( - ctxWith(face), + operationsWith(face), controller, { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] }, ) @@ -1387,7 +1403,7 @@ describe('ModelsSection', () => { }) const before = controller.store.getSnapshot().rows const failure = await removeProviderProfile( - ctxWith(face), + operationsWith(face), controller, { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, ) @@ -1438,7 +1454,7 @@ describe('ModelsSection', () => { unset: vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))), }) const failure = await removeProviderProfile( - ctxWith(face), + operationsWith(face), controller, { settingsNs: 'llm-pi-ai', 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 92d57eb776..59a9bc4cd6 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 @@ -9,6 +9,7 @@ import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ModelsSettingsStore } from '../src/client/store.ts' +import { createModelsOperations } from '../src/client/operations.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -130,6 +131,7 @@ function harness(options: { } // The page plugin's context, scripted down to the namespaces it reaches. const ctx = { remote: face } as never + const operations = createModelsOperations(ctx) const controller = new ModelsSettingsStore(ctx, settingsSchema, new SettingsDescribeMirror(ctx)) const openSection = vi.fn() const complete = vi.fn() @@ -143,7 +145,7 @@ function harness(options: { useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), - ctx, + operations, schema: settingsSchema, t: key => en[key], } 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 96e39a8bfc..1abe0f6664 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 @@ -11,6 +11,8 @@ import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts' +import { createModelsOperations } from '../src/client/operations.ts' +import type { ModelsOperations } from '../src/client/operations.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -155,6 +157,20 @@ function ctxWith(face: object): PageContext { return ctx } +/** + * The cards' injected Host operations over the same script, bound once per face + * as the plugin body binds them: an editor effect keyed by this face would + * otherwise re-probe on every render. + */ +const operations = new WeakMap() +function operationsWith(face: object): ModelsOperations { + const existing = operations.get(face) + if (existing !== undefined) return existing + const bound = createModelsOperations(ctxWith(face)) + operations.set(face, bound) + return bound +} + /** The settings write one card produced, as the scripted face recorded it. */ interface MutateCall { ns: string @@ -189,7 +205,7 @@ async function mountSection(options: Parameters[0] = {}) { const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - ctx: ctxWith(scripted.face), + operations: operationsWith(scripted.face), schema: settingsSchema, t, renderSlot: () => null, @@ -576,7 +592,7 @@ describe('endpoint interrogation', () => { const scripted = scriptedFace() render( , ) @@ -701,7 +717,7 @@ describe('provider rows', () => { render( null} @@ -725,7 +741,7 @@ describe('hand-declared providers', () => { taken={['openai']} protocols={PROTOCOLS} revision={7} - ctx={ctxWith(scripted.face)} + operations={operationsWith(scripted.face)} t={t} readOnly={false} onClose={onClose} @@ -1152,7 +1168,7 @@ describe('hand-declared providers', () => { it('surfaces a refused write without closing', async () => { const refused = vi.fn(() => Promise.resolve(remoteFail('read-only settings', 'settings/rejected'))) - const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: refused }).face) }) + const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ mutate: refused }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1166,7 +1182,7 @@ describe('hand-declared providers', () => { it('translates a create refused by a newer namespace revision', async () => { const conflicting = vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings/conflict'))) - const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: conflicting }).face) }) + const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ mutate: conflicting }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1180,7 +1196,7 @@ describe('hand-declared providers', () => { it('reports a stored profile whose key write was refused', async () => { const set = vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))) - const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ set }).face) }) + const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ set }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index b9ae865929..b0eab4996d 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,5 +1,6 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -23,7 +24,14 @@ export const inject = ['slots', 'remote'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - const toolInject = () => ({ home: ctx.remote.$host.home }) + // Host facts are plain reads; a reset is what announces the generation that + // published them, so the views re-read on it instead of freezing the value + // the entry's first render saw (inject results are memoized per registration). + const hostHome: HostObservable = { + getSnapshot: () => ctx.remote.$host.home, + subscribe: listener => ctx.on('connection/reset', listener), + } + const toolInject = () => ({ hooks: { hostHome } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index bf10b4d3b7..fc38496e6d 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,7 @@ /** Tool UI slot declarations and their composed component props. */ -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { + HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, +} from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -47,8 +49,15 @@ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ export type ToolHostHomeInjected = { - /** Host account home, absent until the Connection is ready. */ - home: string | undefined + hooks: { + /** + * Host account home, absent until the Connection is ready. A hook rather + * than a value: the renderer memoizes an entry's inject result for the + * registration's lifetime, so a home read there would freeze at whatever + * the first render saw. + */ + hostHome: HostObservable + } } /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 1114d29146..7e10afd82c 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,8 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, home, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostHome, t, }: ToolTreeProps) { + const home = useHostHome(value => value) const block = node.data.root return ( ) { + block, cwd, useHostHome, t, +}: Pick) { + const home = useHostHome(value => value) const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) 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 5740154547..26743e3387 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 @@ -49,7 +49,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - home, + useHostHome: ((selector: (value: string | undefined) => unknown) => selector(home)) as ToolTreeProps['useHostHome'], t, } as unknown as ToolTreeProps } 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 e301fc4834..756d71877e 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -157,7 +157,7 @@ export function renderToolDetails( return selector(home)} t={t} /> } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index d33944a914..7bd9d670f4 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -87,9 +87,15 @@ export type DirectoryPickingHooks = PropsHooks + } /** * Start a New Session in a Workspace: reuse-or-create its blank session and * open it; without an explicit workspace, inherit the current Session diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index ac2e5c6e1a..2f55dd506d 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -89,6 +89,13 @@ export function apply(ctx: Context): void { subscribe: listener => ctx.slots.subscribe(hole, listener), }) const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') + // Host facts are plain reads; a reset is what announces the generation that + // published them, so the rows re-read on it instead of freezing the value + // the entry's first render saw (inject results are memoized per registration). + const hostHome: HostObservable = { + getSnapshot: () => ctx.remote.$host.home, + subscribe: listener => ctx.on('connection/reset', listener), + } const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session inherits @@ -122,8 +129,7 @@ export function apply(ctx: Context): void { await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => workspaces.create(input), - home: ctx.remote.$host.home, - hooks: { directoryFlow: browserFlowSource }, + hooks: { directoryFlow: browserFlowSource, hostHome }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index a312a9b878..09295168c5 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,10 +820,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - home, + useHostHome, renderSlot, t, }: WorkspaceBrowserProps) { + const home = useHostHome(value => value) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 511c6c33f9..87536d4646 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -159,7 +159,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) - expect(browser.home).toBeUndefined() + expect(browser.hooks.hostHome.getSnapshot()).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx new file mode 100644 index 0000000000..1b3e256c97 --- /dev/null +++ b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +/** + * Host home reaches the browsing region through the assembled renderer, which + * memoizes a root entry's inject result for the whole registration — so a home + * read once at first render would freeze there. This spec drives the real slot + * renderer (not a direct `entry.inject()` call, which bypasses that memo) and + * pins that a home learned after first render reaches the rendered rows. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, screen } from '@testing-library/react' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' + +usePinnedBrowserLanguages('zh-CN') + +afterEach(cleanup) +beforeEach(() => { localStorage.clear() }) + +/** Test-owned sidebar shell role: declares and renders the browsing region. */ +type FrameProps = PropsRenderSlots<'sidebar.workspaces'> +function SidebarFrame({ renderSlot }: FrameProps) { + return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })} +} + +/** The assembled sidebar over one Workspace inside the POSIX home the Host reports. */ +async function bench() { + const runtime = await SlotTestRuntime.create() + runtime.releaseWorkspaceSource() + const directoryPicker = {} + const remote = new TestRemote(runtime.ctx) + Object.assign(remote, { directoryPicker }) + runtime.ctx.provide('remote.directoryPicker', directoryPicker as never) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.workspaces.update((draft) => { + draft.items = [{ + workspaceId: 'w1' as WorkspaceId, title: 'Project', path: '/home/u/Documents/project', + sessionIds: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never + }) + await runtime.root.declare( + { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never, + SidebarFrame as never, + ) + await runtime.mount({ inject: [...inject], apply }) + return { runtime, remote } +} + +/** Open the Workspace row's hover card, which is where the home abbreviation shows. */ +function openHoverCard(): void { + const row = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(500) }) +} + +/** Close it again, so the next hover rebuilds the card from current props. */ +function closeHoverCard(): void { + const row = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerLeave(row) + act(() => { vi.advanceTimersByTime(500) }) +} + +describe('Host home in the assembled browsing region', () => { + it('abbreviates the path once a home learned after first render reaches the rows', async () => { + // First render precedes the ready frame: the shell mounts while the carrier + // is still handshaking, so the Host reports no home yet. + const { runtime, remote } = await bench() + remote.$host = { home: undefined, isLoopback: true } + runtime.renderRoot() + vi.useFakeTimers() + try { + openHoverCard() + expect(screen.getByText('/home/u/Documents/project')).toBeTruthy() + closeHoverCard() + + // The ready frame lands: `$host.home` now answers, and the generation is + // announced through the reset every consumer already listens to. + remote.$host = { home: '/home/u', isLoopback: true } + act(() => { runtime.ctx.emit('connection/reset') }) + openHoverCard() + + expect(screen.getByText('~/Documents/project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) +}) 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 e9933f3ea3..afb973a6df 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -84,7 +84,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - home: undefined, + useHostHome: selector => selector(undefined), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, @@ -109,7 +109,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - home: '/home/u', + useHostHome: selector => selector('/home/u'), }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) From 41cd24f3f65e61ee3c9938aaed89e6adad553fb7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:29:02 +0800 Subject: [PATCH 05/12] test(api): cover the non-Error terminal escape in RemoteStream The terminal fold's String(error) arm had no coverage; a generation that rejects with a bare string now pins the marked gateway/internal outcome. --- .../gateway/tests/control-retry.client.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index 7e127fbff0..29736817e0 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -118,6 +118,24 @@ describe('RemoteStream', () => { expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated) }) + it('folds a non-Error terminal escape into a marked gateway/internal failure', async () => { + const stream = new RemoteStream(hostSource(true).connection, { + name: 'fixture stream', + open: () => ({ + [Symbol.asyncIterator]: (): AsyncIterator => ({ + next: vi.fn<() => Promise>>().mockRejectedValue('generation exploded'), + }), + }), + ended: () => new Error('fixture stream ended'), + }) + + await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + isDSHRemoteGatewayError: true, + code: 'gateway/internal', + message: 'generation exploded', + }) + }) + it('passes a marked Remote failure through the terminal boundary verbatim', async () => { const failure = new RemoteError('gateway/internal', 'host stream failed', {}) const stream = supervisor(hostSource(true).connection, [{ terminal: failure }]) From 2b750cfb5173982f73e719b8a1418dba2eb1e2ab Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:32:38 +0800 Subject: [PATCH 06/12] docs(api): document the converged ctx.remote programming surface - new cookbook page adding-a-remote-api (en/zh): the five-step HOW-TO for declaring, failing, registering, consuming, and testing a Remote endpoint. - new Agent Note ctx-remote-failure-vocabulary records this round's decisions and alternatives; the 2026-08-02 and 2026-08-10 notes are rewritten to the shipped facts (RemoteError vocabulary, $host, the retired ApiProxy statements). - package READMEs pick up the new failure-face contracts (typert/protocol, api/gateway, api/remotes, test-support/client-runtime), dsh-util-time gains its README and registry entries, and stale connection/WorkspaceError/legacy-code statements are corrected (ui-settings, ui-settings-models, workspace-controller, docs/subsystems/typert incl. the TypertGatewayErrorCode type-equiv block). - packages/AGENTS.md gains the Remote-failure rule bullet; its doc budget rises 675 -> 714: the bullet is the compressed remainder after relocating detail to the cookbook and the Agent Note. --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 48 +++-- ...026-08-02-typert-remote-method-calls.zh.md | 48 +++-- ...-unary-apiproxy-remote-migration.i18n.yaml | 4 +- ...6-08-10-unary-apiproxy-remote-migration.md | 4 +- ...8-10-unary-apiproxy-remote-migration.zh.md | 4 +- ...28-ctx-remote-failure-vocabulary.i18n.yaml | 6 + ...026-08-28-ctx-remote-failure-vocabulary.md | 88 ++++++++ ...-08-28-ctx-remote-failure-vocabulary.zh.md | 88 ++++++++ docs/cookbook/adding-a-remote-api.i18n.yaml | 6 + docs/cookbook/adding-a-remote-api.md | 197 ++++++++++++++++++ docs/cookbook/adding-a-remote-api.zh.md | 197 ++++++++++++++++++ docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 36 ++-- docs/subsystems/typert.zh.md | 36 ++-- packages/AGENTS.md | 1 + packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 10 +- packages/api/gateway/README.zh.md | 10 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 + packages/api/remotes/README.zh.md | 2 + .../api/workspace-controller/README.i18n.yaml | 4 +- packages/api/workspace-controller/README.md | 2 +- .../api/workspace-controller/README.zh.md | 2 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 4 +- packages/client/ui-settings/README.zh.md | 4 +- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 18 +- .../test-support/client-runtime/README.zh.md | 18 +- packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 22 +- packages/typert/protocol/README.zh.md | 22 +- packages/util/README.i18n.yaml | 4 +- packages/util/README.md | 5 +- packages/util/README.zh.md | 5 +- packages/util/time/README.i18n.yaml | 6 + packages/util/time/README.md | 68 ++++++ packages/util/time/README.zh.md | 68 ++++++ scripts/doc-budgets.manifest.json | 2 +- scripts/doc-standard.spec.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + 46 files changed, 944 insertions(+), 135 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md create mode 100644 .agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md create mode 100644 docs/cookbook/adding-a-remote-api.i18n.yaml create mode 100644 docs/cookbook/adding-a-remote-api.md create mode 100644 docs/cookbook/adding-a-remote-api.zh.md create mode 100644 packages/util/time/README.i18n.yaml create mode 100644 packages/util/time/README.md create mode 100644 packages/util/time/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 53bfd94dd7..35207bb94e 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: b95e3f0dec56287cbec2586921477284d0489a40 -2026-08-02-typert-remote-method-calls.zh.md: 50f04fd44a06ae3914998f0337fef09c75fe707c +2026-08-02-typert-remote-method-calls.md: 9102d04266626a8692e1d3ec5cb8f529d8c34d13 +2026-08-02-typert-remote-method-calls.zh.md: bf9ec58b28c1e1e833d92d76737890d49f0487a3 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index b95e3f0dec..9102d04266 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -6,7 +6,7 @@ English | [中文](2026-08-02-typert-remote-method-calls.zh.md) ## Problem -The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. +The Host API Proxy handled direct method calls, stateful interactions, and Session event streams in one package. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types. This decision covers only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, remain separate designs. @@ -22,7 +22,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T `@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.remote`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket. -`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and Typert lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypertClientRemote` contract through Cordis rather than importing the concrete Gateway implementation. +`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry registers the application's forwarded Cordis event source and the Host facts carried by generation readiness; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypertClientRemote` contract through Cordis rather than importing the concrete Gateway implementation. ## Components and Cordis services @@ -32,7 +32,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | | API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results | -| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and legacy API Proxy fallback | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel | | API Gateway's Client face | `ctx.remote`, `ctx.remote.` | Mounts Remote contributions, materializes each namespace as a traced `remote.` child Service, and delegates canonical calls to `ctx.connection.rpc` | | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | @@ -162,9 +162,9 @@ ctx.typert.contexts Host Context resolvers and Client Context binders Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway caches only the set of SRC-owned endpoint names and discards it whenever the Cordis Service set changes; it retains no descriptor, Service, or provider. Invocation resolves all live objects from current state, so removing a strict definition, Service, or provider makes the corresponding call unavailable without leaving a stale live object. -The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that Typert Service. +The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `gateway/lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that Typert Service. -Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle. +Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. The Session Controller's `ApiSessionAgentController` configures one shared resolver for the `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns `session/agent-busy`. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle. The registry's Host root entry has the complete `TypertRegistryContract` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program. @@ -254,7 +254,7 @@ interface TypertRemoteNamespace$676f616c73 { agentId: SessionId, request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } interface TypertRemoteMap { @@ -262,7 +262,7 @@ interface TypertRemoteMap { agentId: SessionId, request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } interface TypertRemoteNamespaceMap { @@ -273,7 +273,7 @@ interface TypertRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } ``` @@ -288,7 +288,9 @@ agentCtx.remote.goals.create(request) The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteScope('agent')` method also omits a separate Scope identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction. -`TypertClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. +Every generated method resolves to `Promise>`: a call reports its outcome in the result's `ok` branch instead of rejecting, and only an assembly fault (arity, an unmounted method, a missing Context adapter) still throws. A consumer branches on `result.ok`, and reads `result.error.code` when it must distinguish failures; the failure vocabulary itself is [one Remote failure class plus a merged code table](2026-08-28-ctx-remote-failure-vocabulary.md). + +`TypertClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. Beside the generated namespaces, the Gateway's client face adds `$mount`, `$on`, `$stream`, and `$host` — the last exposing the connection's fixed Host facts (`home`, `isLoopback`) as plain reads, so a consumer never injects the carrier to learn them. ## Client Typert and the API Gateway Client face @@ -361,7 +363,7 @@ CI and releases use LIB. Moving all repository coverage to LIB is separate follo ## Host Gateway resolution -The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current Typert local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so Typert definitions and business Services may arrive in either order without making legacy `/api` traffic rescan every Service on each request or letting arbitrary request paths grow the cache. +The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current Typert local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so Typert definitions and business Services may arrive in either order without rescanning every Service on each request or letting arbitrary request paths grow the cache. Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypertLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID. @@ -399,9 +401,9 @@ ctx.connection.rpc.intercept( ) ``` -The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; only an endpoint that is not Remote-owned reaches the legacy API Proxy fallback. +The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; an endpoint that neither an exact Fetch route nor the Gateway claims answers 404. -The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler selects either the Gateway RPC FetchHandler or the API Proxy FetchHandler. Both paths reuse the same request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. The current physical mapping is: +The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler matches the pathname against the exact Fetch routes owners registered on the channel, then against the channel's single interceptor — the Gateway — and answers 404 when neither claims it. Every path on the channel reuses the same request/response envelope, rpcId, serialization, trust, and error transport, and a failure carries the shared `{ code, message, details }` data. The current physical mapping is: ```text POST /api// @@ -438,15 +440,15 @@ ctx.remote.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The adapter converts ordinary Gateway and business-invocation failures to the existing `RpcError` envelope with `code: 'internal'`; an existing RPC error carried by a resolver in `TypertLookupFailure` is returned unchanged, preserving stable error codes for cold-resume failures and ownership fences. The Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection. +Remote does not define a second-layer `{ ok, value/error }` response on the wire. Successful values and failures use the existing RPC response's `result` directly, and the failure branch carries the shared `{ code, message, details }` data. Owners, resolvers, and the Gateway all raise one class, `RemoteError`, whose code comes from the merged `RemoteErrorDetailsMap`: the Host encodes a structurally identified `RemoteError` onto the wire unchanged — including the Gateway's own `gateway/*` assembly codes and a resolver's `session/not-found` or `session/agent-busy` — and folds only an unclassified throw into `gateway/internal`, keeping its diagnostic in the message. The Client face rebuilds an instance for the `RemoteResult` error branch, so `throw result.error` keeps throw semantics. [The failure-vocabulary Agent Note](2026-08-28-ctx-remote-failure-vocabulary.md) owns the code table, its ownership rules, and why discrimination reads `code` instead of `instanceof`. -The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. Typert endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work. +The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. Every request on the shared channel, Typert endpoint or exact Fetch route alike, passes Connection's browser authentication and trusted-host policy before dispatch; the Gateway adds no second policy. Connection's WebSocket migration remains separate follow-up work. ## Connection and protocol boundaries The Client Remote Service owns Remote contributions, namespace Service materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client Remote types. -The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. +The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches an exact registered path to its route owner, a claimed endpoint to the Gateway, and anything else to 404. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface. ## Package boundaries @@ -454,19 +456,19 @@ The Gateway registers only its ownership matcher and RPC handler with Connection - Typert generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information. - Typert runtime: separately stores the current environment's local reflection and imported Remote contributions. - `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges. -- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypertClientRemote` contract. -- Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport. +- `@deepseek-ai/dsh-api-remotes`: the BFF layer; registers the application's forwarded Cordis event source and the Host home carried by generation readiness, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypertClientRemote` contract. +- Connection: owns the single HTTP Server/future WebSocket carrier, the shared `/api` route and its composite FetchHandler, owner-registered exact Fetch routes, the RPC envelope, rpcId, serialization, trust, and error transport. - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries. -- API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods. +- `@deepseek-ai/dsh-api-session-controller`: configures the shared `agent`/`session` lookup and `agent` Host Context resolver, so every Remote endpoint that accepts one of those objects shares one resume and ownership-fence policy. - Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath. ## Shipped scope and deferred work -The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode. +The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed by the shared lookup resolver, while subagent-owned identities retain the `session/agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode. Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision. -The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries. The legacy API Proxy likewise remains under `host/apiproxy` as the fallback for methods not yet migrated to Remote. +The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries. ## Alternatives considered @@ -486,7 +488,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h **Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the Client Remote Service. -**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler. +**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection compose it from owner-registered exact Fetch routes and the channel's single interceptor. ## Verification @@ -496,11 +498,11 @@ The package topology is `api/remotes → api/gateway → client/connection → h - Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace. - Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub. - Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope. -- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation. +- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `session/agent-busy` before business invocation. - The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI. - Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback. - Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`. -- Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged. +- A request that matches neither an exact Fetch route nor a claimed Remote endpoint answers 404 on the same channel, while a withdrawn route stops being served. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 50f04fd44a..bf9ec58b28 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 +Host API Proxy 当时在一个包里同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。 本决策只涵盖一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流仍采用独立设计。 @@ -22,7 +22,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 `@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.remote`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。 -`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 Typert lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 约定,而不导入具体 Gateway 实现。 +`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口注册本应用转发的 Cordis 事件源与随 generation readiness 携带的 Host 事实;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 约定,而不导入具体 Gateway 实现。 ## 组件和 Cordis 服务 @@ -32,7 +32,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | | API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 | -| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截和旧 API Proxy 回退 | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route | | API Gateway 的 Client face | `ctx.remote`、`ctx.remote.` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.` 子 Service,并把规范调用交给 `ctx.connection.rpc` | | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | @@ -162,9 +162,9 @@ ctx.typert.contexts Host Context resolvers and Client Context binders 每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 只缓存 SRC 所认领的 endpoint 名称集合,并在 Cordis Service 集合发生变化时整体丢弃该集合;它不保留 descriptor、Service 或提供方。调用时会从当前状态解析所有活对象,因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 -lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 Typert Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 +lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `gateway/lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 Typert Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。 -业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent`、`session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。 +业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。Session Controller 的 `ApiSessionAgentController` 为 `agent`、`session` lookup 和 `agent` Host Context 配置同一个共享 resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回 `session/agent-busy`。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。 Registry 的 Host 根入口拥有完整 `TypertRegistryContract` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。 @@ -254,7 +254,7 @@ interface TypertRemoteNamespace$676f616c73 { agentId: SessionId, request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } interface TypertRemoteMap { @@ -262,7 +262,7 @@ interface TypertRemoteMap { agentId: SessionId, request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } interface TypertRemoteNamespaceMap { @@ -273,7 +273,7 @@ interface TypertRemoteScopeMap { 'agent:goals/create': ( request: CreateGoalRequest, signal?: AbortSignal, - ) => Promise + ) => Promise> } ``` @@ -288,7 +288,9 @@ agentCtx.remote.goals.create(request) Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteScope('agent')` 方法也省略独立的 Scope identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。 -`TypertClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。 +每个生成方法都解析为 `Promise>`:调用把结果报告在 `ok` 分支里而不是 reject,只有装配故障(arity、未挂载的方法、缺失的 Context adapter)仍然抛出。消费方按 `result.ok` 分支,需要区分失败时读 `result.error.code`;失败词汇本身是[单一 Remote 失败类加一张合并码表](2026-08-28-ctx-remote-failure-vocabulary.zh.md)。 + +`TypertClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。除生成的 namespace 之外,Gateway client face 还提供 `$mount`、`$on`、`$stream` 与 `$host`——最后这项把连接的固定 Host 事实(`home`、`isLoopback`)作为普通值读取暴露,消费方无需为此注入载体。 ## Client Typert 与 API Gateway Client face @@ -361,7 +363,7 @@ CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工 ## Host Gateway 解析 -Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 Typert local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 Typert definition 与业务 Service 可以按任意顺序到达,同时既不会让旧 API Proxy 的 `/api` 流量在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。 +Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 Typert local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 Typert definition 与业务 Service 可以按任意顺序到达,同时既不会在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。 每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypertLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。 @@ -399,9 +401,9 @@ ctx.connection.rpc.intercept( ) ``` -Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;只有不属于 Remote 的 endpoint 才进入旧 API Proxy 回退。 +Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;既不匹配精确 Fetch route、也不被 Gateway 认领的 endpoint 返回 404。 -Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 再选择 Gateway RPC FetchHandler 或 API Proxy FetchHandler;两条路径复用同一 request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是: +Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 先用 pathname 匹配各 owner 在该 channel 上注册的精确 Fetch route,再匹配该 channel 唯一的 interceptor——即 Gateway——两者都不认领时返回 404。该 channel 上的每条路径复用同一 request/response envelope、rpcId、序列化、trust 与错误传输,失败则携带共享的 `{ code, message, details }` 数据。当前物理映射是: ```text POST /api// @@ -438,15 +440,15 @@ ctx.remote.goals.create(sessionId, request, signal?) → Client result codec 验证并返回 CreateGoalResult ``` -Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。adapter 把普通 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;resolver 通过 `TypertLookupFailure` 携带的既有 RPC error 则原样返回,使冷恢复失败和 ownership fence 保持稳定错误码。Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。 +Remote 不在 wire 上定义第二层 `{ ok, value/error }` response。成功值与失败都直接使用既有 RPC response 的 `result`,失败分支携带共享的 `{ code, message, details }` 数据。owner、resolver 与 Gateway 抛的都是同一个类 `RemoteError`,其码来自合并后的 `RemoteErrorDetailsMap`:Host 把结构识别出的 `RemoteError` 原样编码上 wire——包括 Gateway 自己的 `gateway/*` 装配码,以及 resolver 的 `session/not-found`、`session/agent-busy`——只把未归类的 throw 折成 `gateway/internal`,并把诊断串留在 message 里。Client face 为 `RemoteResult` 的错误分支重建实例,因此 `throw result.error` 的 throw 语义成立。[失败词汇 Agent Note](2026-08-28-ctx-remote-failure-vocabulary.zh.md) 持有码表、落点规则,以及为什么判别读 `code` 而不用 `instanceof`。 -Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。Typert endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。 +Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。共享 channel 上的每个请求——无论是 Typert endpoint 还是精确 Fetch route——都先过 Connection 的浏览器认证与 trusted-host 策略再分发;Gateway 不叠加第二套策略。Connection/WebSocket 迁移后续独立完成。 ## Connection 与协议边界 Client Remote Service 负责 Remote contribution、namespace Service 实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client Remote 类型。 -Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 +Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 把精确注册路径分发给它的 route owner、把已认领 endpoint 分发给 Gateway,其余一律 404。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。 ## 包边界 @@ -454,19 +456,19 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H - Typert generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。 - Typert runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。 - `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。 -- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypertClientRemote` 约定向业务包暴露合并后的 Remote 类型。 -- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。 +- `@deepseek-ai/dsh-api-remotes`:BFF 层;注册本应用转发的 Cordis 事件源与随 generation readiness 携带的 Host home,选择 Client `/remote` contribution,并通过共享的 `TypertClientRemote` 约定向业务包暴露合并后的 Remote 类型。 +- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与其复合 FetchHandler、各 owner 注册的精确 Fetch route、RPC envelope、rpcId、序列化、trust 和错误传输。 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。 -- API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。 +- `@deepseek-ai/dsh-api-session-controller`:配置共享的 `agent`/`session` lookup 与 `agent` Host Context resolver,因此每个接收这些对象的 Remote endpoint 共用同一套恢复与 ownership fence 策略。 - 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。 ## 已交付范围与后续工作 -已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。 +已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时由该共享 resolver 恢复,subagent-owned identity 保持 `session/agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。 Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。 -包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。旧 API Proxy 同样保留在 `host/apiproxy` 下,作为尚未迁移到 Remote 的方法的回退路径。 +包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。 ## Alternatives considered @@ -486,7 +488,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS **让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 Client Remote Service 显式挂载。 -**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。 +**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 用各 owner 注册的精确 Fetch route 与该 channel 唯一的 interceptor 组合出它。 ## 验证 @@ -496,11 +498,11 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS - 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。 - 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。 - Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。 -- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。 +- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `session/agent-busy`。 - Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。 - 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。 - 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。 -- 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。 +- 既不匹配精确 Fetch route、也不属于已认领 Remote endpoint 的请求在同一 channel 上返回 404,而已撤回的 route 随即停止服务。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index 5868c44f02..0f52ac4e28 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: b98c7ee95b61ec00a5cab3106e812a6f17fc0a15 -2026-08-10-unary-apiproxy-remote-migration.zh.md: 74bca8fd72f3e41075f5a44eba116fe127343bb2 +2026-08-10-unary-apiproxy-remote-migration.md: ee93276b08e204b8c10c22c9fbb890a73691c5a9 +2026-08-10-unary-apiproxy-remote-migration.zh.md: 63eb30a1c079c4f5beb3c6aa328ae31f9dbc51b2 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md index b98c7ee95b..ee93276b08 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -31,10 +31,10 @@ Simple unary operations live on their natural business Remote owner. The busines | `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. | | `host.openPath` | `session/openWorkspacePath` | The Session-aware Client resolves relative paths against the known workspace before `SessionController` hands them to the native opener. | -| `host.describe` | `$events` ready frame plus capability queries | API Remotes sends the Host home with generation readiness; Settings and Session controllers report their native-open capabilities when the corresponding page appears. Unused process metadata is not sent. | +| `host.describe` | `$events` ready frame plus capability queries | API Remotes sends the Host home with generation readiness, and consumers read it as a plain value through `ctx.remote.$host.home` beside `$host.isLoopback`; Settings and Session controllers report their native-open capabilities when the corresponding page appears. Unused process metadata is not sent. | | `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` registers an exact Connection Fetch route and streams the ZIP without a JSON Remote envelope. | -The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`. +The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. The resolver raises a `RemoteError` carrying its own code — `session/not-found` or `session/agent-busy` — and the Gateway encodes that code, message, and details onto the wire unchanged, so a lookup refusal stays distinguishable from `gateway/internal` ([failure vocabulary](2026-08-28-ctx-remote-failure-vocabulary.md)). The native path implementation lives in `@deepseek-ai/dsh-native-command`. Settings controllers select Host-owned targets, while Session-aware Clients resolve workspace paths before calling `SessionController`; the utility only performs platform detection, WSL translation, browser preference, text-editor intent, and shell-free command execution. diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index 74bca8fd72..63eb30a1c0 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -31,10 +31,10 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由 | `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 | | `host.openPath` | `session/openWorkspacePath` | Session-aware Client 先基于已知 workspace 解析相对路径,再由 `SessionController` 交给原生打开器。 | -| `host.describe` | `$events` ready frame 与 capability 查询 | API Remotes 随 generation readiness 发送 Host home;Settings 与 Session controller 在对应页面显示时报告各自的原生打开能力。不发送无人使用的进程元数据。 | +| `host.describe` | `$events` ready frame 与 capability 查询 | API Remotes 随 generation readiness 发送 Host home,消费方通过 `ctx.remote.$host.home` 与并列的 `$host.isLoopback` 以普通值读取;Settings 与 Session controller 在对应页面显示时报告各自的原生打开能力。不发送无人使用的进程元数据。 | | `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` 注册精确的 Connection Fetch 路由,并在没有 JSON Remote envelope 的情况下流式传输 ZIP。 | -共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal`。 +共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。resolver 抛出携带自有码的 `RemoteError`——`session/not-found` 或 `session/agent-busy`——Gateway 把该码、message 与 details 原样编码上 wire,因此 lookup 拒绝与 `gateway/internal` 始终可区分([失败词汇](2026-08-28-ctx-remote-failure-vocabulary.zh.md))。 原生路径实现在 `@deepseek-ai/dsh-native-command` 中。Settings controller 选择 Host 持有的目标,Session-aware Client 则在调用 `SessionController` 前解析 workspace 路径;该工具仅负责平台探测、WSL 转换、浏览器偏好、文本编辑器意图与无 shell 命令执行。 diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml new file mode 100644 index 0000000000..80bdc66709 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md +2026-08-28-ctx-remote-failure-vocabulary.md: 90126d9c23a8f1a1067f5d98f34e24f537774a3a +2026-08-28-ctx-remote-failure-vocabulary.zh.md: c00aa87b30692fed6ae8121e4373f806d063a967 diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md new file mode 100644 index 0000000000..90126d9c23 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md @@ -0,0 +1,88 @@ +# Agent Note: One Remote failure vocabulary for ctx.remote + +Status: implemented + +English | [中文](2026-08-28-ctx-remote-failure-vocabulary.zh.md) + +## Problem + +Every Remote owner package maintained its own failure surface: an `XxxErrorDetailsMap` interface, an `XxxError` union derived from it, and an exit mapping function that translated domain error classes (`UnknownPresetError`, `PresetMountError`, `SessionTitleInvalidError`, and their peers) into a wire failure value. `@deepseek-ai/dsh-typert-protocol` carried two failure classes at once — `TypertRemoteFailure` for a failure an owner reported and `TypertLookupFailure` for one a lookup resolver produced — while `@deepseek-ai/dsh-client-connection` kept a second typed view, `RpcErrorDetailsMap`, that hardcoded domain codes such as `agent-preset-not-found` and `session-not-found` into the carrier. + +One code therefore existed in three places: the owner's table, the carrier's typed view, and whatever union or cast a consumer wrote to narrow it (`result.error as SessionError`). Adding a domain code meant editing all three, and relaying another domain's code meant copying that code into your own table — `SessionErrorDetailsMap` had absorbed five foreign codes this way, across `agent-preset-*`, `subagent-*`, and `workspace-not-found`. + +Failure information was flattened in two places as well. All 17 of the Gateway's own assembly failures (an unmounted method, an ambiguous endpoint, a lookup provider mismatch, a result that fails its codec) reached the wire as `code: 'internal'`, so a client could not separate an assembly fault from a business refusal; owners defensively pre-folded unrelated exceptions into their own domain codes, so a genuine Host bug arrived at the caller as a plausible-looking domain failure. + +Fixed Host facts bypassed `ctx.remote` too: the Host home came from `(ctx.get('connection') as ConnectionHandle).generation.getSnapshot()?.host.home`, so every page that needed one fixed fact injected the carrier and understood its generation store. + +## Decision + +`@deepseek-ai/dsh-typert-protocol` exports one failure class, `RemoteError`: a real `Error` carrying readonly `code` and `details`, the structural marker `isDSHRemoteGatewayError`, and standard `ErrorOptions` (`cause` holds in-process only). The correspondence between codes and details lives in one merge-extensible `RemoteErrorDetailsMap`; `RemoteFailure` is the code-distributed union of instances, and `RemoteResult` keeps its shape. + +```text +export class RemoteError extends Error { + readonly isDSHRemoteGatewayError = true as const + constructor(readonly code: Code, message: string, + readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions) +} +export type RemoteFailure = { [C in RemoteErrorCode]: RemoteError }[RemoteErrorCode] +export type RemoteResult = { ok: true; value: T } | { ok: false; error: RemoteFailure } +``` + +A failure point throws directly: `throw new RemoteError(code, message, details)`. A domain builds no error-class family and writes no exit mapping function; only the "classify any provider exception" case keeps one `catch`, and inside it `throw new RemoteError(code, messageOf(error), details, { cause: error })`. An existing exception class that an in-process flow still consumes (`ApiSessionCwdConflict` and its peers) stays as a non-exported private class and converts to a `RemoteError` in one line at the exit. + +A code is a `/` string: `session/not-found`, `gateway/cancelled`, `workspace/invalid-path`, `agent-preset/locked`. The prefix follows the wire-namespace style, so the code itself says who owns it, and relaying another domain's code no longer needs an awkward unprefixed name. + +## Code ownership + +A code has exactly one declaration site, and the site follows from both who produces it and who can see the declaration — declaration merging only applies where the augmenting file enters the current program, so the home must be a package every producer already sees: + +- **Carrier codes**: `gateway/bad-request`, `gateway/cancelled`, and `gateway/internal` are declared by the protocol and reachable everywhere. +- **Gateway assembly codes**: the 17 `gateway/*` codes are declared in `packages/api/gateway/src/remote-error-codes.ts` with the uniform `TypertGatewayFaultDetails { endpoint, field? }` details; that module is face-neutral and each face imports it, so both programs see the same entries. +- **Produced by several packages**: when two or more packages throw the same code, the declaration lands in the lowest layer both already depend on. `session/not-found` lands in `@deepseek-ai/dsh-session` (session-controller and workspace-controller both depend on it), and `workspace/not-found` lands in `@deepseek-ai/dsh-workspace` (no dependency edge exists between the two API packages, so the capability package is their only shared layer). +- **Single producer**: a code only one package throws lands in that producer. `subagent/not-found` and `agent-preset/conflict` therefore live in session-controller — it is their only thrower in the repository, and neither the subagent nor the agent-presets table declares them. + +What two domains share is validation logic, not a code. `session/invalid-time-zone` and `subagent/invalid-time-zone` are two codes each declared and thrown by its own domain, and both endpoints canonicalize through `canonicalClientTimeZone()` from `@deepseek-ai/dsh-time`; no client branches on this code, so splitting it costs nothing while merging it would recreate the reachability problem. + +## Discrimination by code + +Discrimination always reads `code` and never uses `instanceof`. Client and Host are separately bundled programs, and a worker transport bundles the page half once more, so several copies of the same class exist and prototype identity across copies does not hold. The mechanism layer reads the structural marker through the protocol's `remoteErrorOf(value)`, and the Gateway client face additionally exports `isRemoteFailure(error)` for a consumer's catch site; both read the marker, never the class. + +Business code usually needs neither function: the `ok: false` branch of `RemoteResult` is already a typed `RemoteFailure`, so `if (result.error.code === 'session/not-found')` narrows `details` to that code's shape with no cast. A site that must propagate the failure writes `throw result.error` — it is a real `Error`, with a working stack and `message`. + +The client plane does not construct `RemoteError`; the one exception is the Gateway's own client face, which rebuilds an instance from wire data in `invoke()` and folds carrier throws at stream boundaries into the same vocabulary. A test double that needs a failure value takes `RemoteError` from `@deepseek-ai/dsh-client-test-runtime` instead of making a client package import the protocol as a value. Assertions match the code (plus details fields where they matter) with `toMatchObject`: `RemoteError` is an `Error`, its own-key set differs from the former literal, and `toEqual` fails on it. + +## Fixed Host facts + +`ctx.remote.$host` exposes two fixed facts: `home: string | undefined` and `isLoopback: boolean`. It is a getter on the Client Remote service reading the connection handle captured at service construction — `home` comes from the ready frame in the generation snapshot (`undefined` before ready), `isLoopback` from the carrier. There is no store, no subscription, and no generation counter. + +Refresh after a reconnect rides the existing signal: the Client Remote emits `connection/reset` when it connects, and a consumer that must re-read listens for that or for its own domain's remote event rather than turning `$host` into a subscribable object. Consumers therefore no longer inject `connection`: the `@deepseek-ai/dsh-client-connection` consumer allowlist shrinks to hmr, frontend-static, bundle/web-app, session-log-export, webworker-runtime, and the gateway and api-remotes assemblies. + +## What the wire carries + +The envelope is unchanged: the wire still carries `{ code, message, details }` data, and `RemoteError` is each side's in-process carrier for it. On the Host, `rpcFailure()` collapses to two branches — a structurally identified `RemoteError` is encoded as-is, everything else folds into `gateway/internal` — and carrier-signal cancellation uses the same vocabulary (the `RemoteInvocationCancelled` class is deleted, and its four throw points raise `RemoteError('gateway/cancelled', …)`). + +Three wire-visible behaviors follow. The Gateway's 17 assembly codes travel as themselves, so a client can handle "method not mounted" separately from a business refusal. Owners do not pre-fold unrelated exceptions: an unclassified throw reaches the Gateway, which folds it into `gateway/internal` once and keeps the diagnostic chain in `message`. A client unary call aborted by its caller answers `gateway/cancelled`, matching the code the Host would have produced even when the local throw wins the race against the wire round-trip. + +The carrier keeps only the open wire shape. `ConnectionRpcFailure` and `ConnectionRpcResult` in `@deepseek-ai/dsh-client-connection` carry no domain-code knowledge, and its `transportError()` produces `gateway/internal`; the only home for the typed view is now the protocol's `RemoteFailure`. + +## Alternatives considered + +**A `RemoteFault` error-class family per domain.** Giving each domain (or each code) its own `Error` subclass reads as more object-oriented, but it splits one fact — the code — across class identity and a field, and cross-realm discrimination has to fall back to the field anyway. Class identity then becomes pure overhead: every domain maintains a subclass, exports it, and explains it in prose, while consumers still branch on `code`. One class plus one code table trades that weight for a single declaration line. + +**`attempt` / `unwrap` / `remoteFailureOf` wrappers at call sites.** A wrapper saves one `if` per call site, but it turns `RemoteResult` from the canonical shape into "first pass it through a library function," and both styles then coexist indefinitely; `unwrap` additionally turns "failure is a normal result" back into an exception flow, against the Remote face's contract of never rejecting. The `remoteErrorOf` that survives serves the mechanism layer and test assertions only — business code holds either a typed `result.error` or a failure it threw itself. + +**A `host/updated` event with a subscribed `$host` store.** A subscription would refresh automatically when the Host home changes, but `home` and `isLoopback` are fixed for the lifetime of one connection, so a store, generation, and subscription lifecycle would tax every page that only wants one read. Reconnection already has a signal (`connection/reset`) and business invalidation rides each domain's remote event, so fixed facts stay plain reads. + +**Putting local, non-wire failures in the code table.** ui-goal's `no-current-goal` never crosses a process boundary; admitting it would mix entries only one client package cares about into a shared vocabulary and would suggest it has wire semantics. Local failures keep their own local types, and the code table describes the Remote vocabulary alone. + +## Consequences + +Adding a domain code is one declaration merge plus one throw: no mapping function, error class, and carrier typed view to keep in step. The cost is that the home now requires a judgment — it must be reachable from every producer — and that judgment only surfaces once a second producer appears; `workspace/not-found` moved from workspace-controller to the capability package exactly that way, which also gave `@deepseek-ai/dsh-workspace` a type-only protocol dependency. + +Prefixing the code strings changes the wire strings wholesale, so codes embedded in connection fixtures, assertions on both the Host and Client sides, and spec-local declarations all move in one pass. The pre-release stance accepts that single cut; the same rename after a release would need a compatibility window. + +The type of `details` follows from the code, so a code-and-details mismatch is rejected at compile time. The other face of that is every throw site having to supply the code's required detail fields: the protocol makes `issues` optional on `gateway/bad-request` precisely so a business validation point with no codec issues still writes `{}`. + +`RemoteError` is an `Error`, so it keeps `message` and `cause` through any logger and through `errorChain()`; but `cause` holds only in-process, and the wire carries exactly `code`, `message`, and `details`. Cross-realm discrimination always reads the structural marker, and any new transport (a worker, a bundle split) must carry that marker or an equivalent marker frame across, or failure values degrade into plain `Error`s. + +Consumer signatures for Remote methods are uniformly `Promise>`, matching the generated projection described in [the method-call surface](2026-08-02-typert-remote-method-calls.md); the ledger for the unary endpoints is [the unary endpoint migration](2026-08-10-unary-apiproxy-remote-migration.md). diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md new file mode 100644 index 0000000000..c00aa87b30 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md @@ -0,0 +1,88 @@ +# Agent Note: One Remote failure vocabulary for ctx.remote + +Status: implemented + +[English](2026-08-28-ctx-remote-failure-vocabulary.md) | 中文 + +## Problem + +每个 Remote owner 包各自维护一套失败面:一个 `XxxErrorDetailsMap` 接口、由它派生的 `XxxError` union,以及一个出口映射函数,把域内错误类(`UnknownPresetError`、`PresetMountError`、`SessionTitleInvalidError` 等)翻译成 wire 失败值。`@deepseek-ai/dsh-typert-protocol` 同时携带两个失败类——owner 主动上报用 `TypertRemoteFailure`,lookup resolver 产生的用 `TypertLookupFailure`——而 `@deepseek-ai/dsh-client-connection` 又保留了第二份 typed 视图 `RpcErrorDetailsMap`,把 `agent-preset-not-found`、`session-not-found` 这类域码硬编码进载体层。 + +于是一个码同时存在三处:owner 的表、载体的 typed 视图、以及消费方为窄化而写的 union 或 cast(`result.error as SessionError`)。新增一个域码要改三处,跨域转述一个别人的码则要把对方的码复制进自己的表——`SessionErrorDetailsMap` 就收编了 `agent-preset-*`、`subagent-*`、`workspace-not-found` 五个他域码。 + +失败信息也在两处被压平。Gateway 自己的 17 个装配失败(未挂载的方法、歧义 endpoint、lookup provider 不匹配、结果未过 codec 等)一律以 `code: 'internal'` 上 wire,client 无法把装配 bug 与业务拒绝区分开;owner 又出于防御把无关异常预折成自己的域码,于是一个真正的 Host bug 会以一个看起来合理的域失败到达调用方。 + +Host 固定事实同样绕过了 `ctx.remote`:Host home 取自 `(ctx.get('connection') as ConnectionHandle).generation.getSnapshot()?.host.home`,任何只需要一条固定事实的页面都得注入载体并理解它的 generation store。 + +## Decision + +`@deepseek-ai/dsh-typert-protocol` 导出唯一的失败类 `RemoteError`:一个真 `Error`,带只读 `code` 与 `details`、结构标记 `isDSHRemoteGatewayError`,以及标准 `ErrorOptions`(`cause` 只在进程内有效)。码与 details 的对应关系收进一张 merge-extensible 的 `RemoteErrorDetailsMap`;`RemoteFailure` 是按码分布的实例 union,`RemoteResult` 形状不变。 + +```text +export class RemoteError extends Error { + readonly isDSHRemoteGatewayError = true as const + constructor(readonly code: Code, message: string, + readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions) +} +export type RemoteFailure = { [C in RemoteErrorCode]: RemoteError }[RemoteErrorCode] +export type RemoteResult = { ok: true; value: T } | { ok: false; error: RemoteFailure } +``` + +失败点直接 `throw new RemoteError(code, message, details)`。域内不再建错误类家族,也不再写出口映射函数;只有「把任意 provider 异常归类」这一种场景保留一个 `catch`,并在其中 `throw new RemoteError(code, messageOf(error), details, { cause: error })`。进程内仍需消费的既有异常类(`ApiSessionCwdConflict` 等)保留为不导出的私有类,在出口一行转成 `RemoteError`。 + +码是 `<语义域>/<理由>` 形式的字符串:`session/not-found`、`gateway/cancelled`、`workspace/invalid-path`、`agent-preset/locked`。前缀与 wire namespace 同风格,读者从码本身就能看出它属于谁,跨域转述时也不再需要一个别扭的无前缀名。 + +## Code ownership + +一个码只有一个声明处,落点由「谁生产它」和「声明对谁可达」共同决定——声明合并只在增补文件进入当前 program 时生效,所以正家必须是每个生产者都能看见的包: + +- **载体码**:`gateway/bad-request`、`gateway/cancelled`、`gateway/internal` 由 protocol 声明,人人可达。 +- **Gateway 装配码**:17 个 `gateway/*` 由 `packages/api/gateway/src/remote-error-codes.ts` 声明,details 统一为 `TypertGatewayFaultDetails { endpoint, field? }`;该模块 face-neutral,Host 与 Client 两面各自 import,因此两个 program 看到同一批条目。 +- **跨包共产**:两个及以上不同包抛同一个码时,声明落到双方都已依赖的最低层。`session/not-found` 落 `@deepseek-ai/dsh-session`(session-controller 与 workspace-controller 都依赖它),`workspace/not-found` 落 `@deepseek-ai/dsh-workspace`(session-controller 与 workspace-controller 之间没有依赖边,能力包是唯一共同下层)。 +- **单一生产者**:只有一个包抛的码落生产者包。`subagent/not-found` 与 `agent-preset/conflict` 因此落 session-controller——全仓只有它抛这两个码,subagent 与 agent-presets 的码表里都没有它们。 + +共享的是校验逻辑,不是码。`session/invalid-time-zone` 与 `subagent/invalid-time-zone` 是两个域各自声明、各自抛出的两个码,两个端点共用 `@deepseek-ai/dsh-time` 的 `canonicalClientTimeZone()` 做规范化;client 对这个码没有分支语义,拆码的成本是零,而合成一个码就会重新制造可达性问题。 + +## Discrimination by code + +判别一律读 `code`,从不用 `instanceof`。Client 与 Host 是两个独立打包的 program,worker 传输还会把页面侧再分一次包,因此同一个类会存在多份副本,跨副本的原型链身份不成立。机制层用 protocol 的 `remoteErrorOf(value)` 读结构标记,Gateway client face 另外导出 `isRemoteFailure(error)` 供消费方在 catch 里判别;两者都只看标记,不看类。 + +业务代码通常连这两个函数都不需要:`RemoteResult` 的 `ok: false` 分支已经是类型化的 `RemoteFailure`,`if (result.error.code === 'session/not-found')` 就把 `details` 窄化到该码的形状,无需 cast。需要向上抛的站点直接 `throw result.error`——它是真 `Error`,栈与 `message` 都成立。 + +client 面不构造 `RemoteError`:唯一例外是 Gateway 的 client face 本身,它在 `invoke()` 里按 wire 数据重建实例、在流边界把载体 throw 折进同一词汇。测试替身要构造失败值时从 `@deepseek-ai/dsh-client-test-runtime` 取 `RemoteError`,而不是让 client 包值引入 protocol。断言用 `toMatchObject` 判 code(必要时加 details 字段):`RemoteError` 是 `Error`,own key 集合与旧字面量不同,`toEqual` 会失败。 + +## Fixed Host facts + +`ctx.remote.$host` 暴露两条固定事实:`home: string | undefined` 与 `isLoopback: boolean`。它是 Client Remote service 上的 getter,读的是 service 构造期取得的 connection 句柄——`home` 来自 generation 快照的 ready frame(ready 之前是 `undefined`),`isLoopback` 来自载体。没有 store、没有订阅、没有 generation 计数器。 + +重连后的刷新走既有信号:Client Remote 在连上时 emit `connection/reset`,需要重取的消费方监听它或各域自己的 remote event,而不是让 `$host` 变成一个可订阅对象。因此消费方不再注入 `connection`:`@deepseek-ai/dsh-client-connection` 的消费白名单收缩到 hmr、frontend-static、bundle/web-app、session-log-export、webworker-runtime、gateway 与 api-remotes 装配。 + +## What the wire carries + +envelope 不变:wire 上仍是 `{ code, message, details }` 数据,`RemoteError` 是两端各自的进程内载体。Host 侧 `rpcFailure()` 收敛为两分支——结构识别出的 `RemoteError` 原样编码,其余折成 `gateway/internal`;载体信号取消也走同一词汇(`RemoteInvocationCancelled` 类整体删除,四个 throw 点改抛 `RemoteError('gateway/cancelled', …)`)。 + +三条 wire 可见行为随之确定。Gateway 的 17 个装配码按语义上 wire,client 因此能把「方法未挂载」与「业务拒绝」分开处理。owner 不预折无关异常:未归类的 throw 交给 Gateway 折一次 `gateway/internal`,诊断串保留在 `message` 里。client 一元调用被调用方 abort 时答 `gateway/cancelled`,即使本地 throw 抢在 wire 往返之前赢得竞争,也与 Host 会给出的码一致。 + +载体层只保留开放的 wire 形状。`@deepseek-ai/dsh-client-connection` 的 `ConnectionRpcFailure`/`ConnectionRpcResult` 不含任何域码知识,其 `transportError()` 产出 `gateway/internal`;typed 视图的正家从此只有 protocol 的 `RemoteFailure`。 + +## Alternatives considered + +**每域一套 `RemoteFault` 错误类家族。** 让每个域(或每个码)有自己的 `Error` 子类,看起来更 OO,但它把「码」这一条信息拆成了类身份加字段两处,跨 realm 又只能退回判字段——于是类身份成为纯粹的负担:每个域要维护子类、导出它、在文档里解释它,而消费方仍然只能判 code。单类加一张码表把这份重量换成了一行声明。 + +**在调用点加 `attempt` / `unwrap` / `remoteFailureOf` 包装函数。** 包装能让调用点少写一个 `if`,但它把 `RemoteResult` 这个 canonical 形状变成了「先过一层库函数」,两种风格会长期并存;`unwrap` 还会把「失败是正常结果」重新变成异常流,与 Remote 面不 reject 的契约背道而驰。被保留的 `remoteErrorOf` 只服务机制层与测试断言,业务代码拿到的要么是已类型化的 `result.error`、要么是自己抛的,不需要它。 + +**`host/updated` 事件加订阅式 `$host` store。** 订阅能在 Host home 变化时自动刷新,但 home 与 isLoopback 在一条连接内是固定事实,为它引入 store、generation 与订阅生命周期,等于让每个只想读一次的页面都承担一套状态管理。重连是已有信号(`connection/reset`),业务失效走各域 remote event,固定事实保持普通值读取。 + +**把不上 wire 的本地失败也纳入码表。** 例如 ui-goal 的 `no-current-goal`:它从不跨进程,纳入码表会让共享词汇混入只有一个 client 包关心的条目,还会误导读者以为它有 wire 语义。本地失败保持各自的本地类型,码表只描述 Remote 词汇。 + +## Consequences + +新增一个域码是一处 declaration merging 加一个 throw:不再有映射函数、错误类、载体 typed 视图三处联动。代价是落点需要判断——正家必须对每个生产者可达,而这条判断只有在真的出现第二个生产者时才显现;`workspace/not-found` 就是这样从 workspace-controller 迁到能力包的,并为此给 `@deepseek-ai/dsh-workspace` 加了一条 type-only 的 protocol 依赖。 + +码字符串带前缀后,wire 字符串整体变化,connection fixture 内嵌的码、host 与 client 两侧断言、spec 本地 declare 一次性同步。发布前阶段接受这次一波切;发布后同样的改名需要一个兼容期。 + +`details` 的类型由码决定,因此码与 details 的搭配错误在编译期就被拒。反面是每个抛点都要给全 details 的必填字段:protocol 把 `gateway/bad-request` 的 `issues` 设为可选,正是为了让没有 codec issues 的业务校验点仍然只写 `{}`。 + +`RemoteError` 是 `Error`,所以它进任何日志与 `errorChain()` 都保留 `message` 与 `cause`;但 `cause` 只在进程内成立,wire 上只有 `code`、`message`、`details` 三个字段。跨 realm 的判别永远读结构标记,任何新增的传输(worker、bundle 分片)都必须把标记或等价的 marker 帧带过去,否则失败值会退化为普通 `Error`。 + +Remote 方法的消费端签名统一为 `Promise>`,与[方法调用面](2026-08-02-typert-remote-method-calls.zh.md)描述的生成投影一致;一元调用的迁移账本见[一元端点迁移](2026-08-10-unary-apiproxy-remote-migration.zh.md)。 diff --git a/docs/cookbook/adding-a-remote-api.i18n.yaml b/docs/cookbook/adding-a-remote-api.i18n.yaml new file mode 100644 index 0000000000..89781a29df --- /dev/null +++ b/docs/cookbook/adding-a-remote-api.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-remote-api.md +adding-a-remote-api.md: 0f89101e9bc3ff961d465c109f033150e93e04f6 +adding-a-remote-api.zh.md: 0c5a57b539ecd98cb88790260a3fb8e7b41afff6 diff --git a/docs/cookbook/adding-a-remote-api.md b/docs/cookbook/adding-a-remote-api.md new file mode 100644 index 0000000000..0f89101e9b --- /dev/null +++ b/docs/cookbook/adding-a-remote-api.md @@ -0,0 +1,197 @@ +# Cookbook: adding a Remote API + +English | [中文](adding-a-remote-api.zh.md) + +Adding or changing a `ctx.remote` endpoint takes the five steps on this page: declare the method, declare its failures, register it on the package, consume it on the Client, and test it. Decorator semantics, lookup resolution, the generation pipeline, and the `/api` route are the mechanism and belong to the [API Gateway reference](../api-gateway.md); this page gives the action for each step and the conventions it must satisfy. Why the programming interface looks like this is in the [Typert Remote method calls Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md), and why a failure is one `RemoteError` plus a code table is in the [failure vocabulary Agent Note](../../.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md). + +## 1. Declare the API + +The owner is a Host-side Cordis service: extend `TypertRemoteService` so the service key and the wire namespace are bound together, then mark the exposed methods with `@Remote`. Mark the business method itself when its signature already satisfies the wire conventions; write a `remoteExport*` adapter only when the shape has to change (adding `signal`, reordering parameters, exporting another name), and let that adapter call the unrenamed business method. Lookup objects (`Agent`, `Session`) may only occupy top-level parameter positions, and a method that supports cooperative cancellation takes `signal: AbortSignal` as its final parameter. + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +/** One stored note as a Client reads it. */ +export interface NoteRow { + readonly noteId: string + readonly title: string +} + +declare module '@deepseek-ai/cordis' { + interface Context { + notesController: NotesController + } +} + +export class NotesController extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'notesController', { namespace: 'notes' }) + } + + /** + * @param agent - lookup parameter the Gateway resolves from its wire identity. + * @param signal - carrier cancellation, always the final parameter. + * @returns the notes this Agent's session owns. + */ + @Remote('list') + async remoteExportList(agent: Agent, signal: AbortSignal): Promise { + return await this.list(agent, signal) + } + + /** The in-process API the adapter above delegates to, unchanged by it. */ + async list(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + return await Promise.resolve([{ noteId: `${agent.id}-1`, title: 'draft' }]) + } +} +``` + +## 2. Declare the failures + +A Remote failure is one class, `RemoteError`: merge the domain codes into `RemoteErrorDetailsMap` through declaration merging and `throw new RemoteError(code, message, details)` at the failure point. Do not build a family of domain error classes, and do not write an exit-mapping function; an exception unrelated to this endpoint is not pre-classified, because the Gateway folds it into `gateway/internal`. Write a `catch` only to classify an arbitrary provider exception as one domain code, and attach the original exception as `cause`. + +A code reads `/`, and its declaration has four placement rules: + +- One producer only: declare it in the producing package, next to the throw. +- Several packages produce it: declare it in the lowest domain package both depend on (`session/not-found` in `core/session`, `workspace/not-found` in `dsh-workspace`). +- The carrier codes `gateway/bad-request`, `gateway/cancelled`, and `gateway/internal` are declared in protocol, and the Gateway infrastructure codes in gateway — use them, never copy them. +- A local failure that never crosses the wire stays out of the code table; express it with the caller's own type. + +```ts +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No stored note carries that id. */ + 'note/not-found': { readonly noteId: string } + /** The store refused an otherwise valid write. */ + 'note/rejected': { readonly noteId: string } + } +} + +declare const stored: ReadonlyMap +declare function persist(noteId: string, title: string): Promise + +export async function rename(noteId: string, title: string): Promise { + if (!stored.has(noteId)) { + throw new RemoteError('note/not-found', `no note "${noteId}"`, { noteId }) + } + try { + await persist(noteId, title) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new RemoteError('note/rejected', message, { noteId }, { cause: error }) + } +} +``` + +## 3. Register it on the package + +`@Remote` must live in a Loader entry plugin package; when the owner is an abstract seam, the controller goes in the matching package under `packages/api/`. The manifest gains the two generated entries and the protocol peer dependency, while on the Client side the `@deepseek-ai/dsh-api-remotes` assembly mounts the contribution and re-exports the type vocabulary that consumers need. Which generated artifact each entry points at, and how the generation pipeline is ordered, are in the [API Gateway reference](../api-gateway.md). + +```json +{ + "exports": { + "./typert": { "types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" }, + "./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" } + }, + "peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, + "devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" } +} +``` + +Rerun `pnpm run build:lib` after changing a signature, the code table, the namespace, or an export name, because that is what hands the Client its new declarations and codecs; changing only an implementation body needs no regeneration. + +## 4. Consume it on the Client + +The calling plugin declares both `remote` and `remote.` in its `inject`, and the call site writes `ctx.remote..(...)` directly: no `Pick` narrowing, no hand-written method signature, no wire relay object. The result is a `RemoteResult`, so branch on `if (!result.ok)` in place and discriminate by `code` rather than `instanceof` — a code branch narrows `details` on its own. An exception-flow site writes `throw result.error` (it is a real Error); whoever catches it uses `isRemoteFailure` to tell a Remote failure from a local defect and rethrows the defect. Do not write a defensive catch: a Remote call does not reject, and an assembly mistake should crash. + +Fixed Host facts come from `ctx.remote.$host`: `home` and `isLoopback` are plain reads with no subscription and no generation counter, and `home` is `undefined` until the first ready frame. Refresh after a reconnect through `ctx.on('connection/reset')` or a domain's own remote event. When the caller aborts a unary call, the outcome is `gateway/cancelled` on the error branch rather than a throw. + +```ts ignore-check +import type { Context } from '@deepseek-ai/cordis' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' + +export const inject = ['remote', 'remote.notes'] + +declare const ctx: Context + +/** Store-side read: the error branch is handled where the code is meaningful. */ +export async function noteTitles(): Promise { + const result = await ctx.remote.notes.list() + if (!result.ok) { + if (result.error.code === 'note/not-found') return [] + throw result.error + } + return result.value.map(row => row.title) +} + +/** Action-side: a Remote failure becomes copy; a local fault keeps crashing. */ +export async function renderTitles(): Promise { + try { + return (await noteTitles()).join(', ') + } catch (error: unknown) { + if (!isRemoteFailure(error)) throw error + return `unavailable (${error.code})` + } +} + +/** Fixed Host facts as plain reads. */ +export function hostLabel(): string { + const { home, isLoopback } = ctx.remote.$host + return home ?? (isLoopback ? 'local host' : 'remote host') +} +``` + +## 5. Test it + +On the owner side, assert the code that was thrown: recover the failure with `remoteErrorOf` after catching, then compare `code` and the details fields you care about with `toMatchObject` — never deep-compare the error object with `toEqual`, and never assert `instanceof`. + +```ts +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { expect, it } from 'vitest' + +declare function rename(noteId: string, title: string): Promise + +it('refuses an unknown note before writing', async () => { + const failure = await rename('n-404', 'fresh title').catch((error: unknown) => error) + + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'note/not-found', + details: { noteId: 'n-404' }, + }) +}) +``` + +A Client-side double returns real instances: take the `RemoteError` and `TestRemote` value imports from `@deepseek-ai/dsh-client-test-runtime`, because a value import from the `api-remotes` facade would load the unbuilt assembly chain. `TestRemote.$host` is a plain field a spec assigns directly. + +```ts ignore-check +import { Context } from '@deepseek-ai/cordis' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { expect, it } from 'vitest' + +it('renders the failure code the Host reported', async () => { + const ctx = new Context() + const remote = new TestRemote(ctx, { + notes: { + list: () => Promise.resolve({ + ok: false as const, + error: new RemoteError('note/not-found', 'no note "n-404"', { noteId: 'n-404' }), + }), + }, + }) + remote.$host = { home: '/home/fixture', isLoopback: true } + + await expect(ctx.remote.notes.list()).resolves.toMatchObject({ error: { code: 'note/not-found' } }) +}) +``` + +## Verify + +1. `pnpm run build:lib`: mandatory once a signature, the code table, the namespace, or an export name changed, because it produces the Client declarations and codecs. +2. `pnpm run typecheck`: both the Host and the Client program, where a code merged into an unreachable package turns red. +3. Run both sides' specs by name: `npx vitest run `. +4. Add a recorded-session snapshot when the endpoint reaches a product-visible surface, per the [testing policy](../testing.md). diff --git a/docs/cookbook/adding-a-remote-api.zh.md b/docs/cookbook/adding-a-remote-api.zh.md new file mode 100644 index 0000000000..0c5a57b539 --- /dev/null +++ b/docs/cookbook/adding-a-remote-api.zh.md @@ -0,0 +1,197 @@ +# 实操手册:新增一个 Remote API + +[English](adding-a-remote-api.md) | 中文 + +新增或改动一个 `ctx.remote` 端点按本页五步走:声明方法、声明失败、在包上注册、在 Client 消费、写测试。decorator 语义、lookup 解析、生成管线与 `/api` 路由属于机制,由 [API Gateway 参考](../api-gateway.zh.md)负责;本页给的是每一步的动作与必须遵守的约定。为什么是这套编程面,见 [Typert Remote 方法调用 Agent Note](../../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md);为什么失败面是单个 `RemoteError` 加一张码表,见[失败词汇 Agent Note](../../.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md)。 + +## 1. 声明 API + +owner 是一个 Host 侧 Cordis 服务:继承 `TypertRemoteService` 把 service 键与 wire namespace 一起绑定,再用 `@Remote` 标注对外暴露的方法。业务方法的签名若已符合 wire 约定就直接标注它本身;只有形态需要调整(补 `signal`、换参数顺序、换导出名)才写一个 `remoteExport*` adapter,由它调用不改名的业务方法。lookup 对象(`Agent`、`Session`)只能占顶层参数位,支持协作式取消的方法把 `signal: AbortSignal` 放在最后一位。 + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +/** One stored note as a Client reads it. */ +export interface NoteRow { + readonly noteId: string + readonly title: string +} + +declare module '@deepseek-ai/cordis' { + interface Context { + notesController: NotesController + } +} + +export class NotesController extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'notesController', { namespace: 'notes' }) + } + + /** + * @param agent - lookup parameter the Gateway resolves from its wire identity. + * @param signal - carrier cancellation, always the final parameter. + * @returns the notes this Agent's session owns. + */ + @Remote('list') + async remoteExportList(agent: Agent, signal: AbortSignal): Promise { + return await this.list(agent, signal) + } + + /** The in-process API the adapter above delegates to, unchanged by it. */ + async list(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + return await Promise.resolve([{ noteId: `${agent.id}-1`, title: 'draft' }]) + } +} +``` + +## 2. 声明失败 + +Remote 失败只有一个类 `RemoteError`:域码经 declaration merging 进 `RemoteErrorDetailsMap`,失败点直接 `throw new RemoteError(code, message, details)`。不要建域异常类家族,也不要写出口映射函数;与本端点无关的异常不预先归类,Gateway 会兜底折成 `gateway/internal`。只有"把任意 provider 异常归为一个域码"这一种场景才写 `catch`,并把原始异常挂在 `cause` 上。 + +码名是 `<域>/<理由>`,声明落点四条: + +- 只有一个生产者:声明落生产者包,紧挨抛出点。 +- 多个包共同生产:落双方共同依赖的最低层域包(`session/not-found` 在 `core/session`,`workspace/not-found` 在 `dsh-workspace`)。 +- 载体码 `gateway/bad-request`、`gateway/cancelled`、`gateway/internal` 已在 protocol 声明,Gateway 基础设施码已在 gateway 声明——直接用,不要复制。 +- 不上 wire 的本地失败不进码表,用调用方自己的类型表达。 + +```ts +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** No stored note carries that id. */ + 'note/not-found': { readonly noteId: string } + /** The store refused an otherwise valid write. */ + 'note/rejected': { readonly noteId: string } + } +} + +declare const stored: ReadonlyMap +declare function persist(noteId: string, title: string): Promise + +export async function rename(noteId: string, title: string): Promise { + if (!stored.has(noteId)) { + throw new RemoteError('note/not-found', `no note "${noteId}"`, { noteId }) + } + try { + await persist(noteId, title) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new RemoteError('note/rejected', message, { noteId }, { cause: error }) + } +} +``` + +## 3. 在包上注册 + +`@Remote` 必须落在一个 Loader entry 插件包里;owner 是抽象 seam 时把控制器放进 `packages/api/` 下的对应包。包清单要补两个生成入口与 protocol 的 peer 依赖,Client 侧则由 `@deepseek-ai/dsh-api-remotes` 的 assembly 挂载该贡献并按需转口类型词汇。两个入口分别指向哪个生成产物、生成管线如何排序,见 [API Gateway 参考](../api-gateway.zh.md)。 + +```json +{ + "exports": { + "./typert": { "types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" }, + "./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" } + }, + "peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, + "devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" } +} +``` + +改动了签名、码表、namespace 或导出名之后重跑 `pnpm run build:lib`,Client 才拿得到新的声明与 codec;只改实现体不需要重新生成。 + +## 4. 在 Client 消费 + +调用插件在 `inject` 里同时声明 `remote` 与 `remote.`,调用点直写 `ctx.remote..(...)`:不要用 `Pick` 窄化、不要手写方法签名、不要造 wire 中转对象。结果是 `RemoteResult`,就地 `if (!result.ok)` 分支,判 `code` 而不是 `instanceof`——code 分支会自动窄化 `details`。异常流的站点写 `throw result.error`(它是真 Error);接住它的上层用 `isRemoteFailure` 区分 Remote 失败与本地缺陷,本地缺陷继续往上抛。不要写防御性 catch:Remote 调用不 reject,装配错误就该炸。 + +Host 的固定事实读 `ctx.remote.$host`:`home` 与 `isLoopback` 是普通值读取,没有订阅也没有 generation 计数器,`home` 在第一帧 ready 之前是 `undefined`;重连后的刷新走 `ctx.on('connection/reset')` 或各域自己的 remote 事件。调用方 abort 掉一次一元调用时,结果落在错误分支上的 `gateway/cancelled`,而不是抛出。 + +```ts ignore-check +import type { Context } from '@deepseek-ai/cordis' +import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' + +export const inject = ['remote', 'remote.notes'] + +declare const ctx: Context + +/** Store-side read: the error branch is handled where the code is meaningful. */ +export async function noteTitles(): Promise { + const result = await ctx.remote.notes.list() + if (!result.ok) { + if (result.error.code === 'note/not-found') return [] + throw result.error + } + return result.value.map(row => row.title) +} + +/** Action-side: a Remote failure becomes copy; a local fault keeps crashing. */ +export async function renderTitles(): Promise { + try { + return (await noteTitles()).join(', ') + } catch (error: unknown) { + if (!isRemoteFailure(error)) throw error + return `unavailable (${error.code})` + } +} + +/** Fixed Host facts as plain reads. */ +export function hostLabel(): string { + const { home, isLoopback } = ctx.remote.$host + return home ?? (isLoopback ? 'local host' : 'remote host') +} +``` + +## 5. 测试 + +owner 侧断言抛出的码:捕获后用 `remoteErrorOf` 取出失败,再用 `toMatchObject` 比对 `code` 与需要的 `details` 字段——不要用 `toEqual` 深比对错误对象,也不要断言 `instanceof`。 + +```ts +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { expect, it } from 'vitest' + +declare function rename(noteId: string, title: string): Promise + +it('refuses an unknown note before writing', async () => { + const failure = await rename('n-404', 'fresh title').catch((error: unknown) => error) + + expect(remoteErrorOf(failure)).toMatchObject({ + code: 'note/not-found', + details: { noteId: 'n-404' }, + }) +}) +``` + +Client 侧的替身返回真实例:`RemoteError` 与 `TestRemote` 的值 import 一律取自 `@deepseek-ai/dsh-client-test-runtime`,因为从 `api-remotes` facade 值 import 会拉起尚未构建的装配链。`TestRemote.$host` 是普通字段,spec 直接赋值即可。 + +```ts ignore-check +import { Context } from '@deepseek-ai/cordis' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { expect, it } from 'vitest' + +it('renders the failure code the Host reported', async () => { + const ctx = new Context() + const remote = new TestRemote(ctx, { + notes: { + list: () => Promise.resolve({ + ok: false as const, + error: new RemoteError('note/not-found', 'no note "n-404"', { noteId: 'n-404' }), + }), + }, + }) + remote.$host = { home: '/home/fixture', isLoopback: true } + + await expect(ctx.remote.notes.list()).resolves.toMatchObject({ error: { code: 'note/not-found' } }) +}) +``` + +## 验证 + +1. `pnpm run build:lib`:签名、码表、namespace 或导出名变过就必须重跑,Client 声明与 codec 由它产出。 +2. `pnpm run typecheck`:Host 与 Client 两个 program 都过一遍,码表的 merge 落点错了会在这里红。 +3. 点名跑两侧 spec:`npx vitest run `。 +4. 端点属于产品可见面时补一条录制会话快照,规则见[测试策略](../testing.zh.md)。 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 498532ed69..0a8dc61e44 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/typert.md -typert.md: 20734175cc6b853ae7eeb18e6d5fc91a97cdbe0d -typert.zh.md: e900011f24a5a232ab8764501594b563f320e090 +typert.md: 0f3d2b1afdc1b7713402abc5885dc9551e16ac14 +typert.zh.md: a3c06489707e18433ed9018363c57ec76732e606 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 20734175cc..0f3d2b1afd 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -139,7 +139,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures use the Gateway's in-process error taxonomy, ordinary exceptions are folded by the RPC adapter into the transport's `internal` error code, and existing RPC errors carried by lookup policy through `TypertLookupFailure` are returned unchanged. +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures ride `TypertGatewayError`, whose `gateway/*` codes are ordinary `RemoteError` codes, so the RPC adapter passes every structurally identified `RemoteError` through with its code and details intact and folds only unrecognized exceptions into `gateway/internal`. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -158,23 +158,23 @@ interface InvokeRemoteRequest { ```ts type-equiv /** Stable infrastructure and boundary failures emitted before or after business execution. */ 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' ``` ```ts type-equiv diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index e900011f24..a3c0648970 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -139,7 +139,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败使用 Gateway 的进程内错误分类体系,普通异常由 RPC 适配器归并为传输层的 `internal` 错误码,lookup 策略通过 `TypertLookupFailure` 携带的既有 RPC error 则原样返回。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败由 `TypertGatewayError` 承载,其 `gateway/*` 码就是普通的 `RemoteError` 码,因此 RPC 适配器会把每个经结构识别的 `RemoteError` 连同其 code 与 details 原样放行,只把无法识别的异常归并为 `gateway/internal`。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -158,23 +158,23 @@ interface InvokeRemoteRequest { ```ts type-equiv /** Stable infrastructure and boundary failures emitted before or after business execution. */ 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' ``` ```ts type-equiv diff --git a/packages/AGENTS.md b/packages/AGENTS.md index af47b16740..e1a22cabd1 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,6 +16,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. - **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). +- **Remote failures are one `RemoteError` plus a merged code table** (`RemoteErrorDetailsMap`, `/`): no domain error classes or mapping functions; discriminate by `code`, never `instanceof`; call `ctx.remote` directly without narrowing or wrappers; client `src` never constructs `RemoteError` ([how-to](../docs/cookbook/adding-a-remote-api.md), [rationale](../.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md)). [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index f1d957d143..dfaa6be5bf 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: d10eed46d5534a2459995bd687942d799f009c7b -README.zh.md: 310b44cfe633758089771cda4040679c09373ec2 +README.md: 509f1108a8f940b93b9555399ec59c26325fe4b8 +README.zh.md: 116969c9f04d7791475ff63d8b4602e96c2aaa5b diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index d10eed46d5..509f1108a8 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -28,7 +28,7 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. +The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. @@ -43,7 +43,11 @@ Host composition can register one application event source through `registerRemo Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. -`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. +Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. + +`ctx.remote.$host` reads the fixed Host facts as plain values: `home` (undefined until the first ready frame) and `isLoopback`. It is not a store — no subscription, no generation counter — so a consumer that must react to reconnection listens for `connection/reset` instead of polling it. + +`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. `ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. @@ -62,7 +66,7 @@ No direct effect; invoked business Services own any model-visible result. -- The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypertLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers. +- The Connection adapter answers `gateway/internal` with empty details for dispatch failures and unclassified exceptions; a `RemoteError` thrown by an owner or by Gateway itself crosses the wire with its own code, message, and details. Its `cause` chain and the `TypertGatewayError` subclass identity survive only for same-process callers. - SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. - Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. - `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 310b44cfe6..116969c9f0 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -28,7 +28,7 @@ kind: "package-reference" 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context adapter 解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。因策略而拒绝的 resolver——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 @@ -43,7 +43,11 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source 每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 -`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 +每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context adapter。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。 + +`ctx.remote.$host` 以普通值读取固定的 Host 事实:`home`(首个 ready 帧之前为 undefined)与 `isLoopback`。它不是 store——没有订阅、没有代次计数——所以需要响应重连的消费方去监听 `connection/reset`,而不是轮询它。 + +`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 `ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 @@ -62,7 +66,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source -- Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypertLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 +- Connection 适配器对分发故障与未归类异常答以 `gateway/internal`,且不附带详细信息;拥有方或 Gateway 自己抛出的 `RemoteError` 带着自有码、message 与 details 过线。其 `cause` 链与 `TypertGatewayError` 子类身份只对同进程调用方留存。 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 43991614b9..4521c5c219 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: 7f855bd1cd37c0799f10cadfaf2167fe34f7ea40 -README.zh.md: edad8d735dd914e44bcf283336f78b243e3715d9 +README.md: 228bd53024a61c935e84d6939a7e8ac2873cd7ea +README.zh.md: c8581bd72909602a1bb5509455dc5ed2a195b66f diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 7f855bd1cd..228bd53024 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -29,6 +29,8 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos The Client assembly mounts Commands, credentials, settings, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, Session Controller, and Workspace Controller contributions. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the api-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md). + This package owns no physical transport or Host service discovery. It projects the application selection into generated Remote contributions and an independent Host event source per Client; API Gateway owns endpoints, carriers, cancellation, and reconnection. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. ----- diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index edad8d735d..c8581bd729 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件与 Session 引用、只读 Host 插件清单、消息反馈、Session Controller 和 Workspace Controller contribution。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域的浏览器安全载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 api 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。 + 本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution 和唯一的 Host Cordis event source;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 ----- diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index 3f837582cb..dc501bba2a 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md -README.md: 731a6331e2a19991921c022759f6bbd971cef525 -README.zh.md: f46c78b7b300f26983eee94d3dfdb488d236116e +README.md: d1ce90d09662f1e39d2da5f7fe1b8276f6ad6150 +README.zh.md: e0b7109275464ddb4c9430e15d52dcb9632314a2 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index 731a6331e2..d1ce90d096 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -22,7 +22,7 @@ English | [中文](README.zh.md) ## Use this package -The Host controller serializes mutations whose correctness depends on current registry state and returns stable `WorkspaceError` values for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. +The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable `workspace/*` or `directory-picker/*` code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived Session ids, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index f46c78b7b3..e0b7109275 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -22,7 +22,7 @@ kind: "package-reference" ## 使用本包 -Host 控制器会串行执行正确性取决于当前 registry 状态的变更,并为预期失败返回稳定的 `WorkspaceError` 值。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。 +Host 控制器会串行执行正确性取决于当前 registry 状态的变更,并为预期失败抛出带稳定 `workspace/*` 或 `directory-picker/*` 码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。 Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、已归档 Session id、一元变更回声,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。该包公开与框架无关的快照和订阅,把导航策略与 React hook 留给 UI owner。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 8b69e152a0..1977fc9620 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: 75efb18daf3e4a96fea637f36633f216046ebccd -README.zh.md: f3c7099e242e379be5143352d4045208a8209dc6 +README.md: 920780b2193d6165a10967cc49afcb5b7e193105 +README.zh.md: 4ec9ae43eef9a13ffde22e2a3de0e44d3b6cb51d diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 75efb18daf..920780b219 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -63,7 +63,7 @@ A typed API key is judged on its own field: after trimming, it must be non-empty ### Concurrency and credentials -Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`. After settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, so a failed credential stage retries only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent. Once loaded, the page subscribes to forwarded `settings/document-updated`, `credentials/reference-updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so external edits converge without polling. +Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings/conflict`. After settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, so a failed credential stage retries only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent. Once loaded, the page subscribes to forwarded `settings/document-updated`, `credentials/reference-updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so external edits converge without polling. ### Onboarding coordinator diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index f3c7099e24..4ec9ae43ee 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -63,7 +63,7 @@ kind: "package-reference" ### 并发与凭据 -每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或外部 `settings.yaml` 编辑的并发写入会以 `settings-conflict` 被拒绝。settings 提交后,卡片会在存储凭据前采纳返回的脱敏用户子树与 revision,因此失败的凭据阶段只重试该阶段。删除只会在 profile 指名本页派生的 `_API_KEY` 目标时移除已配置且可写的凭据,然后 unset 该 profile;两个操作都幂等。加载完成后,页面订阅转发的 `settings/document-updated`、`credentials/reference-updated` 与 `llm/adapters-updated` 属主事件,以及本地 `connection/reset`,因此外部编辑无需轮询即可收敛。 +每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或外部 `settings.yaml` 编辑的并发写入会以 `settings/conflict` 被拒绝。settings 提交后,卡片会在存储凭据前采纳返回的脱敏用户子树与 revision,因此失败的凭据阶段只重试该阶段。删除只会在 profile 指名本页派生的 `_API_KEY` 目标时移除已配置且可写的凭据,然后 unset 该 profile;两个操作都幂等。加载完成后,页面订阅转发的 `settings/document-updated`、`credentials/reference-updated` 与 `llm/adapters-updated` 属主事件,以及本地 `connection/reset`,因此外部编辑无需轮询即可收敛。 ### 引导协调器 diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 9a230d0026..e59417b4c8 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5 -README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5 +README.md: 1dbefefe51086a68d1f2afd36d098a118337f0b9 +README.zh.md: 7009b4a7f156a613a7a1ef4e38b14c11d40fa85a diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 3e4970bff9..1dbefefe51 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Feature plugins use this package to store and edit their preferences without re-implementing transport or schema handling. Mount it once per composition; it injects `connection` and `remote` and owns the single `settings.describe` reader in the browser. +Feature plugins use this package to store and edit their preferences without re-implementing transport or schema handling. Mount it once per composition; it injects the `remote` service with its `settings` namespace and owns the single `settings.describe` reader in the browser. ### Binding a namespace @@ -51,7 +51,7 @@ The package realizes one ownership rule: the browser keeps one shared mirror of ### The describe mirror -The plugin injects `connection` and `remote` and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). +The plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). ### Scope derivation diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index a527dfe21a..7009b4a7f1 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -功能插件用本包存储与编辑自己的偏好设置,而无需重新实现传输层或 schema 处理。每个组合挂载一次即可;它注入 `connection` 与 `remote`,并持有浏览器中唯一的 `settings.describe` 读取方。 +功能插件用本包存储与编辑自己的偏好设置,而无需重新实现传输层或 schema 处理。每个组合挂载一次即可;它注入 `remote` 服务及其 `settings` 命名空间,并持有浏览器中唯一的 `settings.describe` 读取方。 ### 绑定命名空间 @@ -51,7 +51,7 @@ kind: "package-reference" ### Describe 镜像 -插件注入 `connection` 与 `remote`,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 +插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 ### Scope 派生 diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 17eb6ee673..ad1a1787a7 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md -README.md: bf8c751b77cd5989ee4174b98d7e945c7a20347d -README.zh.md: e3035049a2e2a2bdd6320f448da3b93b05381866 +README.md: 085b819258f235bcbda0d5406c52fc03a12b81d5 +README.zh.md: e2292802b19109cc6e39100fb9c0ff5147a78497 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index bf8c751b77..085b819258 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -46,6 +46,22 @@ await runtime.dispose() A registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3` → `frame`) so `.snap` files stay structural, and collapses `` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead of the auto frame; `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis and is idempotent. +### Scripting Remote answers and failures + +`TestRemote` is the double for the `ctx.remote` face: it registers itself plus one service per scripted namespace so a plugin injecting `remote.` unparks, drives `$on` subscriptions from an explicit test event driver, and exposes `$host` as a plain mutable field a spec assigns to script a homed or non-loopback Host. This package is also where a UI spec takes the `RemoteError` constructor as a value — the `dsh-api-remotes` facade cannot carry it, because a value import from a spec would pull that assembly's unbuilt `/remote` artifact chain. + +Script a failure by the code the Host would answer with, and assert the same way production code discriminates — on `code`, never on the class: + +```text +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' + +remote.goals.create.mockResolvedValue({ + ok: false, + error: new RemoteError('goal/not-found', 'goal "g1" does not exist', { goalId: 'g1' }), +}) +expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') +``` + ### When to use it Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is browser-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only. @@ -78,7 +94,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` test doubles and `FixtureSession` behavior stubs | | [`src/fixtures.ts`](src/fixtures.ts) | Plain fixture builders: conversation snapshots, workspace list state | | [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `` fingerprint) | -| [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC | +| [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | | [`src/settings-scope.ts`](src/settings-scope.ts) | `stubSettingsScope` with test-driven publications and a write spy | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the mounted production packages own theirs) | diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index e3035049a2..e2292802b1 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -46,6 +46,22 @@ await runtime.dispose() 注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3` → `frame`),使 `.snap` 文件只含结构,并把 `` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)` 而非自动 frame;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态,且幂等。 +### 脚本化 Remote 应答与失败 + +`TestRemote` 是 `ctx.remote` 面的替身:它把自己连同每个被脚本化的命名空间各注册一个服务,使注入 `remote.` 的插件得以解除挂起;`$on` 订阅由显式的测试事件驱动器推动;`$host` 是普通可变字段,套件直接赋值即可脚本化带 home 或非 loopback 的 Host。UI 套件也在本包取用 `RemoteError` 构造器这个值——`dsh-api-remotes` facade 承载不了它,因为从套件发起的值 import 会拉起该装配尚未构建的 `/remote` 产物链。 + +按 Host 会答的码来脚本化失败,并以生产代码同样的方式断言——判 `code`,绝不判类: + +```text +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' + +remote.goals.create.mockResolvedValue({ + ok: false, + error: new RemoteError('goal/not-found', 'goal "g1" does not exist', { goalId: 'g1' }), +}) +expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') +``` + ### 何时使用 当功能套件要在真实运行时下检验 slot、store、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是浏览器侧测试基础设施:永远不触及模型请求,feature 包仅以 `devDependencies` 依赖之。 @@ -78,7 +94,7 @@ await runtime.dispose() | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` 测试替身与 `FixtureSession` 行为桩 | | [`src/fixtures.ts`](src/fixtures.ts) | 普通 fixture 构造器:会话快照、workspace 列表状态 | | [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`` 指纹) | -| [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身 | +| [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | | [`src/settings-scope.ts`](src/settings-scope.ts) | 带测试驱动发布与写入 spy 的 `stubSettingsScope` | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;所挂载的生产包拥有各自的不变式) | diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 437641bc61..25d7006dab 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/typert/protocol/README.md -README.md: 34e685b841e3bf98d1b23306ed90fd3ca8381f79 -README.zh.md: d85cda322f9a3117a4b7bbe4405140b75d5915b8 +README.md: 9c8aa6bd3e480f76cbeab97f969ef8facf6769e1 +README.zh.md: 8cd267213647db54010ddd4f6e63cfc0ec71b608 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index 34e685b841..9c8aa6bd3e 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -46,7 +46,22 @@ Generation turns the method into a wire endpoint under the service's namespace; ### Associating Host objects and Contexts with wire identities -Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. Host and Client Context adapters both map `Context` to a wire identity and that identity back to `Context`; the Host adapter also owns the stable wire declaration. Host composition may override its synchronous or asynchronous resolver. A policy rejection can throw `TypertLookupFailure` to carry an adapter-owned failure value to the caller. +Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. Host and Client Context adapters both map `Context` to a wire identity and that identity back to `Context`; the Host adapter also owns the stable wire declaration. Host composition may override its synchronous or asynchronous resolver. A resolver that refuses on policy grounds throws `RemoteError` with its own code, which reaches the caller unchanged. + +### Reporting and reading a Remote failure + +One class carries every Remote failure: `RemoteError`, holding a stable `/` code and the details typed for that code. This package declares the universal carrier codes (`gateway/bad-request`, `gateway/cancelled`, `gateway/internal`) and owns `RemoteErrorDetailsMap`, the merge-extensible table every other package extends beside its own throwing code: + +```text +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'goal/not-found': { readonly goalId: string } + } +} +throw new RemoteError('goal/not-found', `goal "${id}" does not exist`, { goalId: id }) +``` + +An owner throws at the failure point; no package writes an error-class family or an exit-mapping function. A caller discriminates by `code` — never by `instanceof` — and a `code` branch narrows `details` with no cast, because `RemoteFailure` is the code-discriminated union of `RemoteError` instances. Infrastructure that must recognize a failure carried across a module or realm copy of the class calls `remoteErrorOf(value)`, which reads a structural marker instead of the prototype chain. ### Receiving forwarded Host events on the Client @@ -82,8 +97,9 @@ Every namespace, method, lookup, and Context segment must satisfy `isTypertRemot | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation, `TypertLookupFailure` | -| [`src/types.ts`](src/types.ts) | Protocol maps, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | +| [`src/index.ts`](src/index.ts) | Decorators, Gateway bindings, `remoteMethods`, segment validation | +| [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` and the structural `remoteErrorOf` recognizer | +| [`src/types.ts`](src/types.ts) | Protocol maps, `RemoteErrorDetailsMap`, `RemoteResult`, `InvocationDescriptor`, codecs, provider contracts, registry interfaces, `TypertClientRemote` | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion | diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index d85cda322f..8cd2672136 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -46,7 +46,22 @@ export class GoalService extends TypertRemoteService { ### 把 Host 对象与 Context 关联到 wire identity -复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host 与 Client Context adapter 都把 `Context` 映射为 wire identity,也把该 identity 映射回 `Context`;Host adapter 还拥有稳定 wire 声明。Host 组合可以覆盖其同步或异步 resolver。策略拒绝可以抛出 `TypertLookupFailure`,把适配器拥有的失败值带给调用方。 +复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host 与 Client Context adapter 都把 `Context` 映射为 wire identity,也把该 identity 映射回 `Context`;Host adapter 还拥有稳定 wire 声明。Host 组合可以覆盖其同步或异步 resolver。因策略而拒绝的 resolver 抛出带自有码的 `RemoteError`,该码原样到达调用方。 + +### 报告与读取 Remote 失败 + +所有 Remote 失败都由一个类承载:`RemoteError`,携带稳定的 `/` 码,以及按该码定型的 details。本包声明通用载体码(`gateway/bad-request`、`gateway/cancelled`、`gateway/internal`),并拥有 `RemoteErrorDetailsMap`——可合并扩展的码表,其他每个包都在自己的抛出点旁扩展它: + +```text +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'goal/not-found': { readonly goalId: string } + } +} +throw new RemoteError('goal/not-found', `goal "${id}" does not exist`, { goalId: id }) +``` + +拥有方在失败点直接抛出;没有任何包再写错误类家族或出口映射函数。调用方按 `code` 判别——绝不用 `instanceof`——且 `code` 分支无需 cast 即收窄 `details`,因为 `RemoteFailure` 就是 `RemoteError` 实例按码判别的 union。需要识别跨模块或跨 realm 类副本传来的失败时,基础设施调用 `remoteErrorOf(value)`,它读结构标记而不是原型链。 ### 在 Client 侧接收转发的 Host 事件 @@ -82,8 +97,9 @@ Host 装配以转发给消费端的 Cordis 事件扩展 `TypertRemoteEventSelect | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验、`TypertLookupFailure` | -| [`src/types.ts`](src/types.ts) | 协议映射、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | +| [`src/index.ts`](src/index.ts) | 装饰器、Gateway 绑定、`remoteMethods`、段校验 | +| [`src/remote-error.ts`](src/remote-error.ts) | `RemoteError` 与结构式识别函数 `remoteErrorOf` | +| [`src/types.ts`](src/types.ts) | 协议映射、`RemoteErrorDetailsMap`、`RemoteResult`、`InvocationDescriptor`、编解码器、提供方约定、注册表接口、`TypertClientRemote` | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件 | diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index dfba9d3271..9e92a22e37 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/README.md -README.md: d1f48f165952a283b231e416b3391585cfee81a8 -README.zh.md: 69eb0cf9bb5845a4aeeb3880ef70fac049efbade +README.md: dc7e2912ecc77ffb9710a45bf4c127b80b825ab6 +README.zh.md: 46878a4d0001e7daf42078074c2e82a5bcaf461c diff --git a/packages/util/README.md b/packages/util/README.md index d1f48f1659..dc7e2912ec 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -1,5 +1,5 @@ --- -description: "Package map for the zero-dependency utility family: atomic file writes, branded ids, harness home paths, the launch environment, native commands, output retention, and timeouts." +description: "Package map for the zero-dependency utility family: atomic file writes, branded ids, harness home paths, the launch environment, native commands, output retention, time zones, and timeouts." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, UUIDs, Harness-home paths, launch environments, native commands, output retention, and timeout handling. Every package here is a library: it registers no service or event, and the consuming capability retains the business semantics. +The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, UUIDs, Harness-home paths, launch environments, native commands, output retention, time-zone canonicalization, and timeout handling. Every package here is a library: it registers no service or event, and the consuming capability retains the business semantics. ## Table of Contents @@ -34,6 +34,7 @@ Each package provides one primitive; open a package page for how to use it. | [`native-command/`](native-command/README.md) | Runs host-native commands directly, never through a shell string | | [`workspace-path/`](workspace-path/README.md) | Provides browser-safe Workspace path and display helpers | | [`output-retention/`](output-retention/README.md) | Bounds model-facing output and reports exact omission metadata | +| [`time/`](time/README.md) | Validates and canonicalizes a caller-reported IANA time zone | | [`timeout/`](timeout/README.md) | Deadline arithmetic, signal fusion, and timeout-versus-cancel classification | ----- diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 69eb0cf9bb..46878a4d00 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -1,5 +1,5 @@ --- -description: "零依赖工具家族的包映射:原子文件写入、品牌化 id、harness 主目录路径、启动环境、原生命令、输出保留与超时。" +description: "零依赖工具家族的包映射:原子文件写入、品牌化 id、harness 主目录路径、启动环境、原生命令、输出保留、时区与超时。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、UUID、Harness home 路径、启动环境、原生命令、输出保留和超时处理。这里的每个包都是库:它不注册服务或事件,业务语义仍由消费它的能力负责。 +`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、UUID、Harness home 路径、启动环境、原生命令、输出保留、时区规范化和超时处理。这里的每个包都是库:它不注册服务或事件,业务语义仍由消费它的能力负责。 ## 目录 @@ -34,6 +34,7 @@ kind: "package-group" | [`native-command/`](native-command/README.zh.md) | 直接运行宿主原生命令,绝不拼 shell 字符串 | | [`workspace-path/`](workspace-path/README.zh.md) | 提供浏览器安全的 Workspace 路径与显示辅助函数 | | [`output-retention/`](output-retention/README.zh.md) | 限制面向模型的输出并报告精确的省略元数据 | +| [`time/`](time/README.zh.md) | 校验并规范化调用方所报的 IANA 时区 | | [`timeout/`](timeout/README.zh.md) | 截止时间运算、信号融合与超时/取消分类 | ----- diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml new file mode 100644 index 0000000000..d87f011269 --- /dev/null +++ b/packages/util/time/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/util/time/README.md +README.md: 40f6b4d9ef50f0f35eb9b143ed31cccce352647c +README.zh.md: f6648415dc1ab89382f92e9f0e6ab9465e7fc2b1 diff --git a/packages/util/time/README.md b/packages/util/time/README.md new file mode 100644 index 0000000000..40f6b4d9ef --- /dev/null +++ b/packages/util/time/README.md @@ -0,0 +1,68 @@ +--- +description: "IANA time-zone validation and canonicalization for maintainers accepting a caller-reported zone at a wire boundary." +kind: "package-library" +--- + +# dsh-util-time + +English | [中文](README.zh.md) + +## Summary + +Zero-dependency zone vocabulary for the wire boundaries that accept a caller's time zone. `canonicalClientTimeZone` admits `UTC` or an IANA `Area/Location` name and answers the platform-canonical spelling of it, so an alias never reaches a durable record: a zone identity is stored on messages and re-derived later by another process, where an alias would not compare equal. The library validates and canonicalizes only — it formats no time and owns no failure vocabulary, because each boundary throws its own domain code. + +## Table of Contents + +- [Use this package](#use-this-package) +- [API](#api) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state. + +Call it at the boundary that receives the zone, before the value reaches anything durable. An unusable name answers `undefined`, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. + +----- + + +## API + +```ts +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +``` + +| Export | Role | +|---|---| +| `canonicalClientTimeZone(value)` | Canonical `UTC` or IANA `Area/Location` name for an accepted zone, `undefined` for a blank, padded, abbreviated, single-segment, or platform-unsupported one. | + + +## Model Experience + +Indirectly, through the consumer that records a canonical zone on a durable message, from which `dsh-time-context` renders the turn's model-visible zone instruction and timestamp. + +#### KV Cache effect + +None of its own. The consumer that injects a zone-derived line into a request owns that request's cache behavior. + +## Known Limitations and Deferred Work + + + +- **Alias resolution follows the runtime's ICU data** — which name an alias group canonicalizes to is the platform's answer, so two processes on different Node builds can disagree about it. +- **Validation only** — no formatting, offset arithmetic, DST reasoning, or instant conversion; consumers needing those use `Intl` directly. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md new file mode 100644 index 0000000000..f6648415dc --- /dev/null +++ b/packages/util/time/README.zh.md @@ -0,0 +1,68 @@ +--- +description: "面向在协议边界接收调用方所报时区的维护者,说明 IANA 时区校验与规范化。" +kind: "package-library" +--- + +# dsh-util-time + +[English](README.md) | 中文 + +## 概述 + +零依赖的时区词汇,供接收调用方时区的协议边界使用。`canonicalClientTimeZone` 只接受 `UTC` 或 IANA `Area/Location` 名称,并回答该名称在当前平台上的规范拼写,因此别名不会进入持久记录:时区标识会存在消息上、并由另一个进程稍后重新推导,别名在那里比不相等。本库只做校验与规范化——不格式化任何时间,也不持有失败词汇,因为每个边界抛自己的域码。 + +## 目录 + +- [使用本包](#use-this-package) +- [API](#api) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。 + +在接收时区的那个边界上调用它,让值在进入任何持久物之前先过一遍。不可用的名称回答 `undefined`,由调用方抛出自己的拒绝——Session prompt 用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。 + +----- + + +## API + +```ts +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +``` + +| 导出 | 职责 | +|---|---| +| `canonicalClientTimeZone(value)` | 对接受的时区回答规范的 `UTC` 或 IANA `Area/Location` 名称;空串、带空白、缩写、单段或平台不支持的名称回答 `undefined`。 | + + +## Model Experience + +间接影响,取决于把规范时区记到持久消息上的那个消费方——`dsh-time-context` 据此渲染该轮模型可见的时区指令与时间戳。 + +#### KV Cache effect + +自身没有。把时区派生文本注入请求的那个消费方,对该请求的缓存行为负责。 + +## Known Limitations and Deferred Work + + + +- **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。 +- **只做校验**——不格式化、不做偏移运算、不推导 DST、不做时刻换算;需要这些的消费方直接用 `Intl`。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 1f7f25cb2a..6c93dc54fd 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -5,6 +5,6 @@ "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1150, - "packages/AGENTS.md": 675, + "packages/AGENTS.md": 714, "packages/README.md": 994 } diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index f7c5fd8c51..989540965e 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -82,6 +82,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/util/launch-environment': 'Zero-dependency environment resolver.', 'packages/util/native-command': 'Host-side subprocess runner utility.', 'packages/util/output-retention': 'Zero-dependency retention utility.', + 'packages/util/time': 'Zero-dependency time-zone canonicalization utility.', 'packages/util/timeout': 'Zero-dependency timeout utility.', 'packages/util/workspace-path': 'Zero-dependency Workspace path formatter.', } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index c8f7e84d42..37a488c021 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -55,6 +55,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/code-runtime/code-runtime-python': { kind: 'indirect', reason: 'The CPython subprocess backend delegates model rendering to PTC mode in dsh-tools.' }, 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' }, + 'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, 'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' }, 'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' }, From 39a5a1d7e4f00c8467116c2898b00a58cec88c32 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:52:13 +0800 Subject: [PATCH 07/12] chore(docs): regenerate catalogs, graphs, and the message-feedback golden - gen-cordis-catalog / gen-cordis-inspect-catalog / gen-client-catalog / gen-config-catalog / gen-doc-graphs / gen-module-graph outputs pick up the converged Remote vocabulary (gateway/* codes, RemoteError JSDoc) and the dsh-util-time package; the zh sides of the three English-generated pages follow the same line-number shifts. - the message-feedback protocol golden records the accepted wire change: a boundary-validation failure now reports gateway/input-invalid with structured details instead of a bare internal code. --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 7 +- docs/config-catalog.zh.md | 7 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 12 +-- docs/event-producer-consumer.zh.md | 12 +-- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 28 ++++--- docs/module-graph.zh.md | 28 ++++--- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 16 ++-- docs/subsystems/core.zh.md | 16 ++-- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 9 ++- docs/subsystems/credentials.zh.md | 9 ++- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 2 +- docs/subsystems/llm-streaming.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 2 +- docs/subsystems/session.zh.md | 2 +- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 14 ++-- docs/subsystems/settings.zh.md | 14 ++-- docs/subsystems/skills.i18n.yaml | 4 +- docs/subsystems/skills.md | 2 +- docs/subsystems/skills.zh.md | 2 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 20 ++--- docs/subsystems/subagent.zh.md | 20 ++--- .../src/client/api-catalog.ts | 14 ++-- .../src/client/slot-catalog.ts | 6 +- .../extensions/tool-cordis/src/api-catalog.ts | 74 +++++++++++-------- .../protocol.expected.json | 7 +- 34 files changed, 198 insertions(+), 167 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e243871af1..ad34214f63 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 3c3699228bf5d4c0f49f8134d7c342f452de92c9 -config-catalog.zh.md: 0d96acf23a302d7365e6ebdb4aa3fe7cba4af400 +config-catalog.md: a48163a2ea9b04c99e2322620e207cc0a5aa3b88 +config-catalog.zh.md: bb662eecf45d72eb605a93663e9177cab10fcc34 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c3699228b..a48163a2ea 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -157,7 +157,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -Source: [`packages/preset/agent-presets/src/preset.ts:54`](../packages/preset/agent-presets/src/preset.ts) +Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) @@ -290,7 +290,7 @@ export interface Config { } ``` -Source: [`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:118`](../packages/api/gateway/src/index.ts) @@ -322,7 +322,7 @@ export interface Config { } ``` -Source: [`packages/api/settings-controller/src/index.ts:41`](../packages/api/settings-controller/src/index.ts) +Source: [`packages/api/settings-controller/src/index.ts:37`](../packages/api/settings-controller/src/index.ts) @@ -3549,5 +3549,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-typert-protocol` ([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-util-crypto` ([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) +- `@deepseek-ai/dsh-util-time` ([`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts)) - `@deepseek-ai/dsh-util-workspace-path` ([`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/src/index.ts)) - `@deepseek-ai/dsh-win32-process` ([`packages/subprocess/win32-process/src/index.ts`](../packages/subprocess/win32-process/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0d96acf23a..bb662eecf4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -159,7 +159,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -来源:[`packages/preset/agent-presets/src/preset.ts:54`](../packages/preset/agent-presets/src/preset.ts) +来源:[`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) @@ -292,7 +292,7 @@ export interface Config { } ``` -来源:[`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:118`](../packages/api/gateway/src/index.ts) @@ -324,7 +324,7 @@ export interface Config { } ``` -来源:[`packages/api/settings-controller/src/index.ts:41`](../packages/api/settings-controller/src/index.ts) +来源:[`packages/api/settings-controller/src/index.ts:37`](../packages/api/settings-controller/src/index.ts) @@ -3550,5 +3550,6 @@ export interface Config { - `@deepseek-ai/dsh-typert-protocol`([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry`([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-util-crypto`([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) +- `@deepseek-ai/dsh-util-time`([`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts)) - `@deepseek-ai/dsh-util-workspace-path`([`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/src/index.ts)) - `@deepseek-ai/dsh-win32-process`([`packages/subprocess/win32-process/src/index.ts`](../packages/subprocess/win32-process/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index cf5c3ea52e..9a7f9fcd3c 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 24ed4b92daae6d06a862618e398605fce7063f4f -event-producer-consumer.zh.md: af440aa4155b65a211b7fdc9bce232a264bbe3a7 +event-producer-consumer.md: 53ed72ca93702ad06d63d3fb3b80ab6aba05baaa +event-producer-consumer.zh.md: 8f4b12a8e7dbea96b735320997a5a0a2a4a8a545 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24ed4b92da..53ed72ca93 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:521`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:501`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:507`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:514`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index af440aa415..8f4b12a8e7 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:521`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:501`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:507`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:514`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 73269aadfb..04e07d0c19 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 90c0f2061d27fa6410dd867edb94089440fab006 -module-graph.zh.md: fb3d7c7f6a22b9ff2f6b41ed9745c050aa483759 +module-graph.md: 5c8d6a4f4c9eff9f2ddee548ecd9003e7a5eda8b +module-graph.zh.md: e507a11e6e3e1c220c7947a76cda16645e79512f diff --git a/docs/module-graph.md b/docs/module-graph.md index 90c0f2061d..5c8d6a4f4c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -16,6 +16,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_timeout["timeout"] pkg_util_crypto["util-crypto"] + pkg_util_time["util-time"] pkg_util_workspace_path["util-workspace-path"] end subgraph group_llm["packages/llm"] @@ -365,6 +366,7 @@ flowchart TD pkg_output_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_util_crypto --> pkg_invariants + pkg_util_time --> pkg_invariants pkg_util_workspace_path --> pkg_invariants pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants @@ -556,6 +558,7 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_workspace --> pkg_typert_protocol pkg_llm_deepseek --> pkg_anonymous_user_id pkg_llm_deepseek --> pkg_atomic_write pkg_llm_deepseek --> pkg_attachment @@ -1087,6 +1090,7 @@ flowchart TD pkg_webhook --> pkg_workspace pkg_subagent --> pkg_agent pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_attachment pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_jobs @@ -1103,6 +1107,7 @@ flowchart TD pkg_subagent --> pkg_tools pkg_subagent --> pkg_typert_protocol pkg_subagent --> pkg_user_approval + pkg_subagent --> pkg_util_time pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1253,6 +1258,7 @@ flowchart TD pkg_api_session_controller --> pkg_subagent pkg_api_session_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_time pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway @@ -1350,7 +1356,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess pkg_client_ui_settings --> pkg_api_remotes - pkg_client_ui_settings --> pkg_client_connection pkg_client_ui_settings --> pkg_invariants pkg_client_ui_settings --> pkg_settings pkg_client_locale --> pkg_api_remotes @@ -1370,7 +1375,6 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes - pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1389,7 +1393,6 @@ flowchart TD pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants pkg_cordis_client_runner --> pkg_api_remotes - pkg_cordis_client_runner --> pkg_client_connection pkg_cordis_client_runner --> pkg_client_modules pkg_cordis_client_runner --> pkg_client_ui_renderer pkg_cordis_client_runner --> pkg_client_ui_theme @@ -1503,7 +1506,6 @@ flowchart TD pkg_client_ui_schedule --> pkg_invariants pkg_client_ui_schedule --> pkg_schedule pkg_client_ui_settings_general --> pkg_api_remotes - pkg_client_ui_settings_general --> pkg_client_connection pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_ui_renderer pkg_client_ui_settings_general --> pkg_client_ui_session @@ -1711,6 +1713,7 @@ flowchart TD pkg_client_test_runtime --> pkg_invariants pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent + pkg_client_test_runtime --> pkg_typert_protocol pkg_client_ui_skill --> pkg_api_remotes pkg_client_ui_skill --> pkg_api_session_controller pkg_client_ui_skill --> pkg_client_connection @@ -1743,6 +1746,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`util-time`](../packages/util/time) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-workspace-path`](../packages/util/workspace-path) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1821,7 +1825,7 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | @@ -1909,7 +1913,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | @@ -1927,7 +1931,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1941,14 +1945,14 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | @@ -1961,7 +1965,7 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1979,6 +1983,6 @@ flowchart TD | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index fb3d7c7f6a..e507a11e6e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -18,6 +18,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_timeout["timeout"] pkg_util_crypto["util-crypto"] + pkg_util_time["util-time"] pkg_util_workspace_path["util-workspace-path"] end subgraph group_llm["packages/llm"] @@ -367,6 +368,7 @@ flowchart TD pkg_output_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_util_crypto --> pkg_invariants + pkg_util_time --> pkg_invariants pkg_util_workspace_path --> pkg_invariants pkg_deepseek_llm_api_extensions --> pkg_invariants pkg_scope --> pkg_invariants @@ -558,6 +560,7 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_workspace --> pkg_typert_protocol pkg_llm_deepseek --> pkg_anonymous_user_id pkg_llm_deepseek --> pkg_atomic_write pkg_llm_deepseek --> pkg_attachment @@ -1089,6 +1092,7 @@ flowchart TD pkg_webhook --> pkg_workspace pkg_subagent --> pkg_agent pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_attachment pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_jobs @@ -1105,6 +1109,7 @@ flowchart TD pkg_subagent --> pkg_tools pkg_subagent --> pkg_typert_protocol pkg_subagent --> pkg_user_approval + pkg_subagent --> pkg_util_time pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1255,6 +1260,7 @@ flowchart TD pkg_api_session_controller --> pkg_subagent pkg_api_session_controller --> pkg_typert_protocol pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_time pkg_api_session_controller --> pkg_util_workspace_path pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway @@ -1352,7 +1358,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess pkg_client_ui_settings --> pkg_api_remotes - pkg_client_ui_settings --> pkg_client_connection pkg_client_ui_settings --> pkg_invariants pkg_client_ui_settings --> pkg_settings pkg_client_locale --> pkg_api_remotes @@ -1372,7 +1377,6 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes - pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1391,7 +1395,6 @@ flowchart TD pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants pkg_cordis_client_runner --> pkg_api_remotes - pkg_cordis_client_runner --> pkg_client_connection pkg_cordis_client_runner --> pkg_client_modules pkg_cordis_client_runner --> pkg_client_ui_renderer pkg_cordis_client_runner --> pkg_client_ui_theme @@ -1505,7 +1508,6 @@ flowchart TD pkg_client_ui_schedule --> pkg_invariants pkg_client_ui_schedule --> pkg_schedule pkg_client_ui_settings_general --> pkg_api_remotes - pkg_client_ui_settings_general --> pkg_client_connection pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_ui_renderer pkg_client_ui_settings_general --> pkg_client_ui_session @@ -1713,6 +1715,7 @@ flowchart TD pkg_client_test_runtime --> pkg_invariants pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent + pkg_client_test_runtime --> pkg_typert_protocol pkg_client_ui_skill --> pkg_api_remotes pkg_client_ui_skill --> pkg_api_session_controller pkg_client_ui_skill --> pkg_client_connection @@ -1745,6 +1748,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`util-time`](../packages/util/time) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`util-workspace-path`](../packages/util/workspace-path) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1823,7 +1827,7 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | @@ -1911,7 +1915,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | @@ -1929,7 +1933,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1943,14 +1947,14 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | @@ -1963,7 +1967,7 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1981,6 +1985,6 @@ flowchart TD | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-tool`](../packages/client/ui-tool), [`cordis-client-runner`](../packages/extensions/cordis-client-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 06bfe0a937..90dc78dba3 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: dbe12dfc9bfdafe0f59d7e52eefc5695b1c8d063 -core.zh.md: 50fd2e4e76ee72653c02c2ffeca06042191aa9c4 +core.md: 2f81b9d3456edae88a4f777b376bc5394c32a14e +core.zh.md: e4afda0c3c57acab49dbcfe3f8ce8cb3f08c3d23 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index dbe12dfc9b..2f81b9d345 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -488,8 +488,8 @@ async read(id: string): Promise * 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 @@ -516,8 +516,8 @@ async copy(from: string, id: string, name?: string): Promise * @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 @@ -533,8 +533,8 @@ async remove(id: string): Promise * 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 @@ -585,8 +585,8 @@ async recompose(agentCtx: Context, id: string): Promise * @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 diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 50fd2e4e76..e4afda0c3c 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -498,8 +498,8 @@ async read(id: string): Promise * 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 @@ -526,8 +526,8 @@ async copy(from: string, id: string, name?: string): Promise * @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 @@ -543,8 +543,8 @@ async remove(id: string): Promise * 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 @@ -595,8 +595,8 @@ async recompose(agentCtx: Context, id: string): Promise * @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 diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index b9f925005d..626f298b8b 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/credentials.md -credentials.md: 60cd388fc18fe239b9ee5207d77d3b5561b7a3b3 -credentials.zh.md: 5ca47fac6c36c14eb1ee0aa8a6cb6a8b5e3eb9c4 +credentials.md: 5ae53a19140f423e1c52334c40f25fca7d3ed754 +credentials.zh.md: b6d5d8eb3960edd68a96a32a41c591e61f2c8b5e diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 60cd388fc1..5ae53a1914 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -227,9 +227,10 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie * 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> @@ -238,14 +239,14 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie * 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 /** * 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 ``` diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 5ca47fac6c..b6d5d8eb39 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -227,9 +227,10 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie * 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> @@ -238,14 +239,14 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie * 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 /** * 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 ``` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 19974d41f8..ea90fbb43d 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: e37356d91e75334987b6fc707744f2376df1b2fc -llm-streaming.zh.md: 05f24bed6beff9508db88e60af7b259eda33f474 +llm-streaming.md: 6867ae292d77474bcedc1466ae0ce6b1fc1c92d3 +llm-streaming.zh.md: b75e24f2e9010b4fb08c035f14bc4e91dc3971ef diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index e37356d91e..6867ae292d 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -929,7 +929,7 @@ async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, sig * @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( settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal, ): Promise diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 05f24bed6b..b75e24f2e9 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -935,7 +935,7 @@ async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, sig * @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( settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal, ): Promise diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f459ae1fd9..7624766c51 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: f3c246f7a77386a088f0559d233031bdb8d997f8 -session.zh.md: bc706a11e9b504f6806cbf7c1beb67b972fb4f75 +session.md: d06932171b6fcf0df3470d0236a0e8b0f5aa6c21 +session.zh.md: fc8c4ec53b875614a082777eaa109f1a2514268e diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f3c246f7a7..d06932171b 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -660,7 +660,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @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( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index bc706a11e9..fc8c4ec53b 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -664,7 +664,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @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( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 2efb877591..f03bec4ed3 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/settings.md -settings.md: 3215de191b4ef8fecb373d280c8bc8e4a89bbc7e -settings.zh.md: 0c28ef381ee6ea8e59da64717575fbf80c8a2e5f +settings.md: 1a83e76d908ff19ca0f3abb3b21e10cae0d0685f +settings.zh.md: af204ccfbda419996686ba691ee57e884c18e631 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 3215de191b..1a83e76d90 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -262,14 +262,14 @@ Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/sett ### `ctx.settingsController` — `SettingsController` -Host service backing the generated `ctx.remote.settings` namespace. Every 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. +Host service backing the generated `ctx.remote.settings` namespace. Every 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. ```ts cordis-catalog /** * 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 @@ -285,7 +285,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @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( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise @@ -295,7 +295,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @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( ns: string, section: Record, expectedRevision: number | undefined, ): Promise @@ -307,7 +307,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @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( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise @@ -315,7 +315,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * 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 @@ -324,7 +324,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @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( agentPreset: string, signal: AbortSignal, ): Promise ``` diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 0c28ef381e..af204ccfbd 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -262,14 +262,14 @@ Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/sett ### `ctx.settingsController` — `SettingsController` -Host service backing the generated `ctx.remote.settings` namespace. Every 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. +Host service backing the generated `ctx.remote.settings` namespace. Every 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. ```ts cordis-catalog /** * 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 @@ -285,7 +285,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @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( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise @@ -295,7 +295,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @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( ns: string, section: Record, expectedRevision: number | undefined, ): Promise @@ -307,7 +307,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @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( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise @@ -315,7 +315,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * 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 @@ -324,7 +324,7 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @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( agentPreset: string, signal: AbortSignal, ): Promise ``` diff --git a/docs/subsystems/skills.i18n.yaml b/docs/subsystems/skills.i18n.yaml index 7bf2ead73e..0c7d15ddef 100644 --- a/docs/subsystems/skills.i18n.yaml +++ b/docs/subsystems/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/skills.md -skills.md: 18eb4d3289cec5c7807feab4229096b5799d4168 -skills.zh.md: aa167373cb9736cf24d7bf20dc965e68568826be +skills.md: 8224b91290c110d52d739cd85b3fc7d641f9a7ea +skills.zh.md: 018bd85d74ba40717c741d6600f991f3c6d36d4c diff --git a/docs/subsystems/skills.md b/docs/subsystems/skills.md index 18eb4d3289..8224b91290 100644 --- a/docs/subsystems/skills.md +++ b/docs/subsystems/skills.md @@ -258,7 +258,7 @@ Host service backing `ctx.remote.skills` without activating a cold Agent. * @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 ``` diff --git a/docs/subsystems/skills.zh.md b/docs/subsystems/skills.zh.md index aa167373cb..018bd85d74 100644 --- a/docs/subsystems/skills.zh.md +++ b/docs/subsystems/skills.zh.md @@ -258,7 +258,7 @@ Host service backing `ctx.remote.skills` without activating a cold Agent. * @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 ``` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 325ef7599a..363ee89e12 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: dfadbbdee28607c929e629a20c18af6b55f4f51e -subagent.zh.md: d4ffe30d69aff404733a6e8acbc2d1a19e2276d3 +subagent.md: cedbc8544cb86db9feda4b0a2bcf5a65cf99bfe9 +subagent.zh.md: 4eabe673b0e750e0b3ed5480299b93a377886b13 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index dfadbbdee2..cedbc8544c 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -657,9 +657,9 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise @@ -672,10 +672,10 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise @@ -689,9 +689,9 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise @@ -676,10 +676,10 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise @@ -693,9 +693,9 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise>', + signature: 'search( query: string, signal: AbortSignal, ): Promise>', description: 'Search the Host\'s visible message-content index. Results stay request-local; the list snapshot remains the metadata authority.', parameters: [{ name: 'query', description: 'non-blank literal phrase.' }, { name: 'signal', description: 'cancellation for a superseded search.' }], returns: 'bounded results, or a business/transport error.', @@ -471,7 +471,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ClientRemote', - declaration: 'export interface ClientRemote extends TypertClientRemote {\n $stream(options: RemoteStreamOptions): RemoteStream;\n}', + declaration: 'export interface ClientRemote extends TypertClientRemote {\n $stream(options: RemoteStreamOptions): RemoteStream;\n readonly $host: RemoteHostFacts;\n}', }, { name: 'CommonKeyOf', @@ -551,7 +551,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ISession', - declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyPropsOf', @@ -627,7 +627,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptError', - declaration: 'export interface PromptError {\n readonly op: \'send\' | \'stop\';\n readonly error: ClientFailure;\n}', + declaration: 'export interface PromptError {\n readonly op: \'send\' | \'stop\';\n readonly error: RemoteFailure;\n}', }, { name: 'PropsHooks', @@ -653,6 +653,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PropsStore', declaration: 'export type PropsStore = H extends StoreHandle ? {\n useStore: SnapshotSelectorHook;\n actions: BakedActions;\n} : object;', }, + { + name: 'RemoteHostFacts', + declaration: 'export interface RemoteHostFacts {\n readonly home: string | undefined;\n readonly isLoopback: boolean;\n}', + }, { name: 'RemoteStream', declaration: 'export class RemoteStream implements AsyncIterable> {\n constructor(private readonly connection: Pick, private readonly options: RemoteStreamOptions);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise;\n [Symbol.asyncIterator](): AsyncIterator>;\n}', @@ -727,7 +731,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSnapshot', - declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly pendingSubmissions: readonly PendingSubmission[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: ClientFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', + declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly queue: readonly QueuedMessage[];\n readonly pendingSubmissions: readonly PendingSubmission[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: RemoteFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', }, { name: 'SessionStandardProps', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index b01882d442..4be9be57f0 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -650,7 +650,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:57', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:56', }, { key: 'conversation.input.attachments', @@ -2113,7 +2113,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:59', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:58', }, { key: 'tool.call.toolview', @@ -2170,7 +2170,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:24', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:25', }, { key: 'tool.view.cordis', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 28e8d3dc06..943f60b85d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -186,7 +186,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'One preset\'s composition text with the roster row it belongs to.', parameters: [{ name: 'agentPreset', description: '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.'], }, { signature: 'async copy(from: string, id: string, name?: string): Promise', @@ -199,7 +199,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Copy one preset through the Remote API.', parameters: [{ name: 'from', description: 'the source preset id.' }, { name: 'id', description: 'the new preset id.' }, { name: 'name', description: '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.'], }, { signature: 'async remove(id: string): Promise', @@ -212,7 +212,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Delete one preset through the Remote API.', parameters: [{ name: 'id', description: '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.'], }, { signature: 'serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined', @@ -232,7 +232,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Compose a blank session\'s agent from a different preset and record it.', parameters: [{ name: 'agent', description: 'the session\'s live agent, resolved from the wire identity.' }, { name: 'agentPreset', description: '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.'], }, { signature: 'async standingKeyFor(id?: string): Promise', @@ -732,21 +732,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: '@Remote async describe(refs: string[]): Promise>', description: '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.', - parameters: [{ name: 'refs', description: 'reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`.' }], + parameters: [{ name: 'refs', description: '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.'], }, { signature: '@Remote async set(ref: string, value: string): Promise', description: 'Store one value from a configuration surface. The value crosses the wire in this direction only: no read path returns it.', parameters: [{ name: 'ref', description: 'reference name to store under.' }, { name: 'value', description: '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.'], }, { signature: '@Remote async unset(ref: string): Promise', description: 'Remove one reference from a configuration surface.', parameters: [{ name: 'ref', description: '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.'], }, ], }, @@ -1134,7 +1134,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Remote adapter for one draft provider interrogation.', parameters: [{ name: 'settingsNs', description: 'namespace whose registered discovery serves this draft.' }, { name: 'request', description: 'endpoint, protocol, and one-shot credential to use.' }, { name: 'signal', description: '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.'], }, { signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', @@ -1381,7 +1381,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Open one path prepared by a Session-aware caller on the Host desktop.', parameters: [{ name: 'request', description: 'path after best-effort Session workspace resolution.' }, { name: 'signal', description: '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.'], }, { signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise', @@ -1833,7 +1833,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'List the user-invocable skills visible to one Session composition.', parameters: [{ name: 'request', description: 'Session identity whose cwd and preset select the catalog view.' }, { name: 'signal', description: '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.'], }, ], }, @@ -1951,14 +1951,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'settingsController', summary: 'Host service backing the generated `ctx.remote.settings` namespace.', - description: 'Host service backing the generated `ctx.remote.settings` namespace. Every 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.', + description: 'Host service backing the generated `ctx.remote.settings` namespace. Every 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.', methods: [ { signature: '@Remote describe(): SettingsDescribeValue', description: 'Describe every registered namespace for a configuration page: redacted layered values plus the serialized schema the page renders its form from.', parameters: [], 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.'], }, { signature: '@Remote canOpenAgentPresetDirectory(): boolean', @@ -1971,35 +1971,35 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Merge a patch into one namespace\'s stored user section.', parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'patch', description: 'fields to merge into the user section.' }, { name: 'expectedRevision', description: '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.'], }, { signature: '@Remote replace( ns: string, section: Record, expectedRevision: number | undefined, ): Promise', description: 'Replace one namespace\'s stored user section wholesale.', parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'section', description: 'complete replacement user section.' }, { name: 'expectedRevision', description: '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.'], }, { signature: '@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise', description: 'Apply path-addressed edits to one namespace\'s user section, resolved against the section as stored rather than against whatever the caller last read, then answer with that namespace\'s new redacted view.', parameters: [{ name: 'ns', description: 'namespace key to write.' }, { name: 'ops', description: 'the edits to apply, in order.' }, { name: 'expectedRevision', description: '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.'], }, { signature: '@Remote async openSettingsDocument(signal: AbortSignal): Promise', description: 'Materialize the provider-owned settings document and open it in a native text editor.', parameters: [{ name: 'signal', description: '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.'], }, { signature: '@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise', description: 'Open one user-authored Agent preset directory or return its path when no native opener exists.', parameters: [{ name: 'agentPreset', description: 'preset id resolved against Host-owned roots.' }, { name: 'signal', description: '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.'], }, ], }, @@ -2236,21 +2236,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Remote face of listChildren for one browser: the durable listing plus live Agent activity and the delivery-time parent availability hint. Parent availability is a hint; prompt performs the authoritative check. Named apart from the provider-name list, which owns the member.', parameters: [{ name: 'parentSessionId', description: 'parent session whose direct children are listed.' }, { name: 'signal', description: '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`.'], }, { signature: '@Remote(\'prompt\') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise', description: 'Deliver one browser-authored message to a continuable child through the exact live direct parent, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s FIFO inbox accepted; later execution is independent of this call.', parameters: [{ name: 'request', description: 'durable address, minted identity, content, and optional browser zone.' }, { name: 'signal', description: '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`.'], }, { signature: '@Remote(\'interruptByParent\') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\', ): SubagentInterruptReceipt', description: 'Remote face of interrupt under one durable parent address. No catalog, history, persistence, or parent Agent lookup runs: the core primitive alone authorizes the address against the live Activation, which is what keeps a live child interruptible while its parent Agent is offline. Absent, idle, and already-completed targets are accepted no-ops there.', parameters: [{ name: 'childSessionId', description: 'durable child session id to interrupt.' }, { name: 'parentSessionId', description: 'durable direct parent whose authority is claimed.' }, { name: 'mode', description: '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`.'], }, { signature: 'registerProvider(provider: SubagentProvider): () => void', @@ -3444,7 +3444,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ApiSessionAgentError', - declaration: 'export type ApiSessionAgentError = Extract;', + declaration: 'export type ApiSessionAgentError = RemoteError<\'session/not-found\' | \'session/agent-busy\' | \'gateway/internal\'>;', }, { name: 'ApiSessionAgentResult', @@ -3962,6 +3962,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EncodedImageAttachment', declaration: 'export interface EncodedImageAttachment {\n mediaType: ImageMediaType;\n data: string;\n name?: string;\n}', }, + { + name: 'EncodedImagePromptBlock', + declaration: 'export interface EncodedImagePromptBlock extends EncodedImageAttachment {\n readonly type: \'image\';\n}', + }, { name: 'EpochHeader', declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n}', @@ -4602,6 +4606,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', }, + { + name: 'RemoteError', + declaration: 'export class RemoteError
extends Error {\n readonly isDSHRemoteGatewayError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', + }, + { + name: 'RemoteErrorCode', + declaration: 'export type RemoteErrorCode = keyof RemoteErrorDetailsMap;', + }, + { + name: 'RemoteErrorDetailsMap', + declaration: 'export interface RemoteErrorDetailsMap {\n \'gateway/bad-request\': {\n readonly issues?: readonly object[];\n };\n \'gateway/cancelled\': {};\n \'gateway/internal\': {};\n}', + }, { name: 'RemoteEventHostInfo', declaration: 'export interface RemoteEventHostInfo {\n readonly home: string;\n}', @@ -4786,14 +4802,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionCreateValue', declaration: 'export interface SessionCreateValue {\n readonly sessionId: SessionId;\n readonly agentPreset?: string;\n}', }, - { - name: 'SessionError', - declaration: 'export type SessionError = {\n [Code in keyof SessionErrorDetailsMap]: {\n readonly code: Code;\n readonly message: string;\n readonly details: SessionErrorDetailsMap[Code];\n };\n}[keyof SessionErrorDetailsMap];', - }, - { - name: 'SessionErrorDetailsMap', - declaration: 'export interface SessionErrorDetailsMap {\n \'bad-request\': Record;\n cancelled: Record;\n \'session-not-found\': {\n readonly sessionId: SessionId;\n };\n \'model-unavailable\': {\n readonly provider: string;\n readonly model: string;\n };\n \'session-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedCwd: string;\n readonly existingCwd?: string;\n };\n \'invalid-time-zone\': {\n readonly value: string;\n };\n \'workspace-attach-failed\': {\n readonly sessionId: SessionId;\n readonly workspaceId: string;\n };\n \'workspace-not-found\': {\n readonly workspaceId: string;\n };\n \'agent-preset-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedPreset: string;\n readonly existingPreset?: string;\n };\n \'agent-preset-not-found\': {\n readonly agentPreset: string;\n readonly available: readonly string[];\n };\n \'agent-preset-invalid\': {\n readonly agentPreset: string;\n readonly reason: string;\n };\n \'agent-busy\': {\n readonly reason: string;\n };\n \'attachment-error\': {\n readonly reason: string;\n };\n \'queue-item-not-found\': {\n readonly itemId: MessageId;\n };\n \'steer-unavailable\': {\n readonly itemId: MessageId;\n };\n \'title-invalid\': {\n readonly sessionId: SessionId;\n };\n \'fork-unavailable\': {\n readonly sessionId: SessionId;\n /* …truncated — full shape in source */', - }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -5374,13 +5382,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentListEntry', declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', }, + { + name: 'SubagentPromptContentPart', + declaration: 'export type SubagentPromptContentPart = ContentBlock | EncodedImagePromptBlock;', + }, { name: 'SubagentPromptReceipt', declaration: 'export interface SubagentPromptReceipt {\n readonly messageId: MessageId;\n}', }, { name: 'SubagentPromptRequest', - declaration: 'export interface SubagentPromptRequest {\n readonly requestId: SubagentPromptRequestId;\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'continuable\';\n readonly content: ContentBlock[];\n readonly clientTimeZone?: string;\n}', + declaration: 'export interface SubagentPromptRequest {\n readonly requestId: SubagentPromptRequestId;\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'continuable\';\n readonly content: readonly SubagentPromptContentPart[];\n readonly clientTimeZone?: string;\n}', }, { name: 'SubagentPromptRequestId', diff --git a/snapshots/web/message-feedback-protocol/protocol.expected.json b/snapshots/web/message-feedback-protocol/protocol.expected.json index 536bad41f5..148afb38b5 100644 --- a/snapshots/web/message-feedback-protocol/protocol.expected.json +++ b/snapshots/web/message-feedback-protocol/protocol.expected.json @@ -18,9 +18,12 @@ "result": { "ok": false, "error": { - "code": "internal", + "code": "gateway/input-invalid", "message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation", - "details": {} + "details": { + "endpoint": "messageFeedback/put", + "field": "request" + } } } } From a4f7193d243daffb55814f1fea9f8ce0a32a4715 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:01:54 +0800 Subject: [PATCH 08/12] docs: drop the Remote-failure bullet from packages/AGENTS.md The rule text lives in the cookbook and the failure-vocabulary Agent Note; the AGENTS.md roster and its 675-word budget stay as they were. --- packages/AGENTS.md | 1 - scripts/doc-budgets.manifest.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index e1a22cabd1..af47b16740 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -16,7 +16,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. - **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). -- **Remote failures are one `RemoteError` plus a merged code table** (`RemoteErrorDetailsMap`, `/`): no domain error classes or mapping functions; discriminate by `code`, never `instanceof`; call `ctx.remote` directly without narrowing or wrappers; client `src` never constructs `RemoteError` ([how-to](../docs/cookbook/adding-a-remote-api.md), [rationale](../.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md)). [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 6c93dc54fd..1f7f25cb2a 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -5,6 +5,6 @@ "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1150, - "packages/AGENTS.md": 714, + "packages/AGENTS.md": 675, "packages/README.md": 994 } From f9e8fc8f8af36fceafee4a2c41f506f29eb9c51a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:28:51 +0800 Subject: [PATCH 09/12] fix(api): identity-stable $host facts and a whole-record host info hook - api/gateway: $host caches its RemoteHostFacts record and mints a new one only when home changes, so snapshot readers compare by reference; identity pinned in the client spec. - client/ui-tool, client/ui-workspace: the hooks channel exposes the host facts record as hostInfo and components select the field they need (useHostInfo(info => info.home)); row-component contracts are unchanged. --- packages/api/gateway/src/client/index.ts | 11 ++++++++--- .../api/gateway/tests/gateway.client.spec.ts | 12 +++++++++--- packages/client/ui-tool/src/client/apply.ts | 10 ++++------ .../client/ui-tool/src/client/contract/slots.ts | 17 +++++++++-------- packages/client/ui-tool/src/client/index.ts | 2 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 4 ++-- .../ui-tool/src/client/tool/ToolDetails.tsx | 6 +++--- .../tests/tool-call-tree.client.spec.tsx | 2 +- .../tests/tool-details-render.client.tsx | 2 +- .../ui-workspace/src/client/contract/slots.ts | 11 ++++++----- .../client/ui-workspace/src/client/index.ts | 10 ++++------ .../src/client/rows/WorkspaceBrowser.tsx | 4 ++-- .../ui-workspace/tests/apply.client.spec.ts | 2 +- .../tests/workspace-browser.client.spec.tsx | 4 ++-- 14 files changed, 53 insertions(+), 44 deletions(-) diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 303f311d6a..accf7cafb0 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -144,6 +144,7 @@ class ClientRemoteService extends Service implements ClientRemote { private readonly ownerCtx: Context private readonly connection: ConnectionHandle private readonly namespaces = new Map() + private hostFacts: RemoteHostFacts | undefined private readonly streams = new RemoteStreamMuxClient() private readonly events: ClientRemoteEvents private mutations = Promise.resolve() @@ -183,10 +184,14 @@ class ClientRemoteService extends Service implements ClientRemote { } get $host(): RemoteHostFacts { - return { - home: this.connection.generation.getSnapshot()?.host.home, - isLoopback: this.connection.isLoopback, + // Identity-stable: readers (useSyncExternalStore snapshots, memo inputs) + // compare by reference, so a fresh object is minted only when the fact + // itself changed. isLoopback is fixed for the page lifetime. + const home = this.connection.generation.getSnapshot()?.host.home + if (this.hostFacts === undefined || this.hostFacts.home !== home) { + this.hostFacts = { home, isLoopback: this.connection.isLoopback } } + return this.hostFacts } async $mount(contribution: TypertRemoteContribution): ReturnType { diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index b6a1d12414..d0af7669e9 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -569,13 +569,19 @@ describe('Client Remote transport readiness', () => { await client const remote = ctx.remote - expect(remote.$host).toEqual({ home: undefined, isLoopback: true }) + const beforeReady = remote.$host + expect(beforeReady).toEqual({ home: undefined, isLoopback: true }) + expect(remote.$host).toBe(beforeReady) + live.snapshot = { id: 1, host: { home: '/hosts/primary' } } - expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + const afterReady = remote.$host + expect(afterReady).toEqual({ home: '/hosts/primary', isLoopback: true }) + expect(afterReady).not.toBe(beforeReady) + expect(remote.$host).toBe(afterReady) withdraw() expect(ctx.get('connection')).toBeUndefined() - expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + expect(remote.$host).toBe(afterReady) }) it('starts after Loader settlement and stops the owned loop on disposal', async () => { diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index b0eab4996d..1324e8cbed 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,5 +1,6 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -24,14 +25,11 @@ export const inject = ['slots', 'remote'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - // Host facts are plain reads; a reset is what announces the generation that - // published them, so the views re-read on it instead of freezing the value - // the entry's first render saw (inject results are memoized per registration). - const hostHome: HostObservable = { - getSnapshot: () => ctx.remote.$host.home, + const hostInfo: HostObservable = { + getSnapshot: () => ctx.remote.$host, subscribe: listener => ctx.on('connection/reset', listener), } - const toolInject = () => ({ hooks: { hostHome } }) + const toolInject = () => ({ hooks: { hostInfo } }) 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 fc38496e6d..703e102ac4 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -2,6 +2,7 @@ import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -48,15 +49,15 @@ export interface ToolCallOwnerProps { export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ -export type ToolHostHomeInjected = { +export type ToolHostInfoInjected = { hooks: { /** - * Host account home, absent until the Connection is ready. A hook rather - * than a value: the renderer memoizes an entry's inject result for the - * registration's lifetime, so a home read there would freeze at whatever - * the first render saw. + * Fixed Host facts, reached through a hook rather than injected as values: + * the renderer memoizes an entry's inject result for the registration's + * lifetime, so facts read there would freeze at whatever the first render + * saw. Select the field the view needs (`info => info.home`). */ - hostHome: HostObservable + hostInfo: HostObservable } } @@ -64,9 +65,9 @@ export type ToolHostHomeInjected = { 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 19df19e054..fc65940d0f 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, ToolDetailsProps, ToolHostHomeInjected, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostInfoInjected, 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 7e10afd82c..2a11948077 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,9 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostHome, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostInfo, t, }: ToolTreeProps) { - const home = useHostHome(value => value) + const home = useHostInfo(info => info.home) const block = node.data.root return ( ) { - const home = useHostHome(value => value) + block, cwd, useHostInfo, t, +}: Pick) { + const home = useHostInfo(info => info.home) const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) 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 26743e3387..84ac946e1e 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 @@ -49,7 +49,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useHostHome: ((selector: (value: string | undefined) => unknown) => selector(home)) as ToolTreeProps['useHostHome'], + useHostInfo: ((selector: (info: { home: string | undefined }) => unknown) => selector({ home })) as ToolTreeProps['useHostInfo'], t, } as unknown as ToolTreeProps } 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 756d71877e..43b3286b69 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -157,7 +157,7 @@ export function renderToolDetails( return selector(home)} + useHostInfo={selector => selector({ home, isLoopback: true })} t={t} /> } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 7bd9d670f4..df17251c38 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -28,6 +28,7 @@ import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRu import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSearchResultItem } from '@deepseek-ai/dsh-api-session-controller/client' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { createWorkspaceViewStore } from '../stores.ts' @@ -89,12 +90,12 @@ export type DirectoryPickingHooks = PropsHooks info.home`). */ - hostHome: HostObservable + hostInfo: HostObservable } /** * Start a New Session in a Workspace: reuse-or-create its blank session and diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 2f55dd506d..7c8e1108dc 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -9,6 +9,7 @@ * packages/client/AGENTS.md. */ import type { Context } from '@deepseek-ai/cordis' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { IWorkspaces, WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -89,11 +90,8 @@ export function apply(ctx: Context): void { subscribe: listener => ctx.slots.subscribe(hole, listener), }) const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') - // Host facts are plain reads; a reset is what announces the generation that - // published them, so the rows re-read on it instead of freezing the value - // the entry's first render saw (inject results are memoized per registration). - const hostHome: HostObservable = { - getSnapshot: () => ctx.remote.$host.home, + const hostInfo: HostObservable = { + getSnapshot: () => ctx.remote.$host, subscribe: listener => ctx.on('connection/reset', listener), } const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') @@ -129,7 +127,7 @@ export function apply(ctx: Context): void { await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => workspaces.create(input), - hooks: { directoryFlow: browserFlowSource, hostHome }, + hooks: { directoryFlow: browserFlowSource, hostInfo }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 09295168c5..9d9dd376e4 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,11 +820,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - useHostHome, + useHostInfo, renderSlot, t, }: WorkspaceBrowserProps) { - const home = useHostHome(value => value) + const home = useHostInfo(info => info.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 87536d4646..acc4b1cbc8 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -159,7 +159,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) - expect(browser.hooks.hostHome.getSnapshot()).toBeUndefined() + expect(browser.hooks.hostInfo.getSnapshot()).toMatchObject({ home: undefined }) expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() 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 afb973a6df..8ee31d37c9 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -84,7 +84,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - useHostHome: selector => selector(undefined), + useHostInfo: selector => selector({ home: undefined, isLoopback: true }), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, @@ -109,7 +109,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - useHostHome: selector => selector('/home/u'), + useHostInfo: selector => selector({ home: '/home/u', isLoopback: true }), }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) From 02a542f49f9bfcdf75dc659f1f274acb01e943d6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:37:26 +0800 Subject: [PATCH 10/12] chore(docs): regenerate the slot catalog for the hostInfo hook rename --- .../cordis-client-runner/src/client/slot-catalog.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4be9be57f0..729f8b936f 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -650,7 +650,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:56', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:57', }, { key: 'conversation.input.attachments', @@ -2113,7 +2113,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:58', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:59', }, { key: 'tool.call.toolview', @@ -2170,7 +2170,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:25', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:26', }, { key: 'tool.view.cordis', From 674a1e95a3702f5a927c10a6e45e0f3bf264db31 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:12:43 +0800 Subject: [PATCH 11/12] fix(api): rename the failure marker, harden cross-realm discrimination, and mark the packed-record violation - protocol: isDSHRemoteGatewayError -> isDSHRemoteError (the class is protocol-wide, not Gateway-specific); remoteErrorOf drops its instanceof Error precondition and tests the marker plus a string code structurally, so a marked failure from another realm no longer reads as a local defect. - session-controller: the live-follow packed-record protocol violation now throws a marked RemoteError('gateway/internal'), landing the session in openState=error instead of an unhandled rejection; pinned at the transport and session levels. - util-time: correct the invariant companion's @module name. - regenerate the tool-cordis catalog for the marker rename. --- .../tests/control-retry.client.spec.ts | 4 ++-- .../src/client/transport.ts | 7 +++++- .../tests/session.client.spec.ts | 23 +++++++++++++++++++ .../tests/transport.client.spec.ts | 6 ++++- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/typert/protocol/src/remote-error.ts | 9 +++++--- packages/util/time/src/invariant.ts | 4 ++-- 7 files changed, 45 insertions(+), 10 deletions(-) diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index 29736817e0..c818d507e7 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -108,7 +108,7 @@ describe('RemoteStream', () => { ], carrierFailed) await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ - isDSHRemoteGatewayError: true, + isDSHRemoteError: true, code: 'gateway/internal', message: 'isolated retry failed', details: {}, @@ -130,7 +130,7 @@ describe('RemoteStream', () => { }) await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ - isDSHRemoteGatewayError: true, + isDSHRemoteError: true, code: 'gateway/internal', message: 'generation exploded', }) diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index 69a9b934e1..da2361ad90 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -1,6 +1,7 @@ /** Session-specific adapters for Gateway-owned Remote stream lifecycles. */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { RemoteJournalStream, RemoteSnapshotStream, @@ -60,7 +61,11 @@ function toSessionJournalChange( return { ...change, entries: historyEntries(change.entries) } case 'append': { if (change.entry.type !== 'event') { - throw new Error('session live stream emitted a packed history record') + throw new RemoteError( + 'gateway/internal', + 'session live stream emitted a packed history record', + {}, + ) } return { type: 'append', diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 565dcf99bf..8d7e4319d3 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -99,6 +99,29 @@ describe('Session open', () => { expect(api.followStarts).toHaveLength(2) }) + it('lands a packed live record in openState=error instead of crashing the stream loop', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + expect(session.getSnapshot().openState).toBe('open') + + // The live tail may carry only events; a packed record breaks that contract. + await api.pushFollow(SID, { + type: 'chunks', + event: { + type: 'chunkrow/text-chunks', + seq: 6, + time: 6, + data: { turn: 1, step: 1, index: 0, texts: ['a'], dt: [] }, + }, + } as never) + + await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) + expect(session.getSnapshot().openError).toMatchObject({ + code: 'gateway/internal', message: 'session live stream emitted a packed history record', + }) + }) + it('lands a Gateway-marked stream failure in openState=error', async () => { const { api, session } = makeSession() api.onHistory = () => Promise.reject(new Error('socket died')) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 6e00845e35..c5f50ad7de 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + isRemoteFailure, RemoteStream, RemoteStreamCarrierError, type RemoteStreamOptions, @@ -181,7 +182,10 @@ describe('Session Client stream adapters', () => { await stream.open({}) await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() }) - expect(failed.mock.calls[0]?.[0]).toMatchObject({ + const violation: unknown = failed.mock.calls[0]?.[0] + expect(isRemoteFailure(violation)).toBe(true) + expect(violation).toMatchObject({ + code: 'gateway/internal', message: 'session live stream emitted a packed history record', }) await stream.dispose() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 943f60b85d..740cb44e2c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4608,7 +4608,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RemoteError', - declaration: 'export class RemoteError extends Error {\n readonly isDSHRemoteGatewayError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', + declaration: 'export class RemoteError extends Error {\n readonly isDSHRemoteError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', }, { name: 'RemoteErrorCode', diff --git a/packages/typert/protocol/src/remote-error.ts b/packages/typert/protocol/src/remote-error.ts index ccdd6cd1d8..8805d0e9d4 100644 --- a/packages/typert/protocol/src/remote-error.ts +++ b/packages/typert/protocol/src/remote-error.ts @@ -11,7 +11,7 @@ import type { RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure } from './ty */ export class RemoteError extends Error { /** Structural marker: cross-realm/bundle identification never uses instanceof. */ - readonly isDSHRemoteGatewayError: true = true + readonly isDSHRemoteError: true = true /** * @param code - stable failure code declared in {@link RemoteErrorDetailsMap}. @@ -38,8 +38,11 @@ export class RemoteError extends * @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) { + // Structural, not instanceof: an Error thrown in another realm (iframe, VM) + // fails instanceof Error here, so the marker plus the code field is the test. + if (typeof value === 'object' && value !== null + && (value as { isDSHRemoteError?: unknown }).isDSHRemoteError === true + && typeof (value as { code?: unknown }).code === 'string') { return value as unknown as RemoteFailure } return undefined diff --git a/packages/util/time/src/invariant.ts b/packages/util/time/src/invariant.ts index 2a52f0ffb3..81ded26371 100644 --- a/packages/util/time/src/invariant.ts +++ b/packages/util/time/src/invariant.ts @@ -1,6 +1,6 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-time`. - * @module @deepseek-ai/dsh-time/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-util-time`. + * @module @deepseek-ai/dsh-util-time/invariant */ /* jscpd:ignore-start */ From 73a723f37fe65a24e7663fab9bd6c06aebce112b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:12:46 +0800 Subject: [PATCH 12/12] docs(api): correct the api-gateway reference codes and the failure-vocabulary note - api-gateway reference (en/zh): gateway/lookup-unavailable and gateway/internal replace the pre-convergence codes, and the resolver paragraph states the RemoteError pass-through semantics. - failure-vocabulary note (en/zh): the package is dsh-util-time, the marker is isDSHRemoteError, and discrimination requires no instanceof at all. --- .../2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml | 4 ++-- .../2026-08-28-ctx-remote-failure-vocabulary.md | 8 ++++---- .../2026-08-28-ctx-remote-failure-vocabulary.zh.md | 8 ++++---- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml index 80bdc66709..e659db7435 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md -2026-08-28-ctx-remote-failure-vocabulary.md: 90126d9c23a8f1a1067f5d98f34e24f537774a3a -2026-08-28-ctx-remote-failure-vocabulary.zh.md: c00aa87b30692fed6ae8121e4373f806d063a967 +2026-08-28-ctx-remote-failure-vocabulary.md: fe8cafb6116d73797e1dae07fb28fe52d42c9285 +2026-08-28-ctx-remote-failure-vocabulary.zh.md: 6b75aae454fb9e4d7c4622525220d1949c45404d diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md index 90126d9c23..fe8cafb611 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md @@ -16,11 +16,11 @@ Fixed Host facts bypassed `ctx.remote` too: the Host home came from `(ctx.get('c ## Decision -`@deepseek-ai/dsh-typert-protocol` exports one failure class, `RemoteError`: a real `Error` carrying readonly `code` and `details`, the structural marker `isDSHRemoteGatewayError`, and standard `ErrorOptions` (`cause` holds in-process only). The correspondence between codes and details lives in one merge-extensible `RemoteErrorDetailsMap`; `RemoteFailure` is the code-distributed union of instances, and `RemoteResult` keeps its shape. +`@deepseek-ai/dsh-typert-protocol` exports one failure class, `RemoteError`: a real `Error` carrying readonly `code` and `details`, the structural marker `isDSHRemoteError`, and standard `ErrorOptions` (`cause` holds in-process only). The correspondence between codes and details lives in one merge-extensible `RemoteErrorDetailsMap`; `RemoteFailure` is the code-distributed union of instances, and `RemoteResult` keeps its shape. ```text export class RemoteError extends Error { - readonly isDSHRemoteGatewayError = true as const + readonly isDSHRemoteError: true = true constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions) } @@ -41,11 +41,11 @@ A code has exactly one declaration site, and the site follows from both who prod - **Produced by several packages**: when two or more packages throw the same code, the declaration lands in the lowest layer both already depend on. `session/not-found` lands in `@deepseek-ai/dsh-session` (session-controller and workspace-controller both depend on it), and `workspace/not-found` lands in `@deepseek-ai/dsh-workspace` (no dependency edge exists between the two API packages, so the capability package is their only shared layer). - **Single producer**: a code only one package throws lands in that producer. `subagent/not-found` and `agent-preset/conflict` therefore live in session-controller — it is their only thrower in the repository, and neither the subagent nor the agent-presets table declares them. -What two domains share is validation logic, not a code. `session/invalid-time-zone` and `subagent/invalid-time-zone` are two codes each declared and thrown by its own domain, and both endpoints canonicalize through `canonicalClientTimeZone()` from `@deepseek-ai/dsh-time`; no client branches on this code, so splitting it costs nothing while merging it would recreate the reachability problem. +What two domains share is validation logic, not a code. `session/invalid-time-zone` and `subagent/invalid-time-zone` are two codes each declared and thrown by its own domain, and both endpoints canonicalize through `canonicalClientTimeZone()` from `@deepseek-ai/dsh-util-time`; no client branches on this code, so splitting it costs nothing while merging it would recreate the reachability problem. ## Discrimination by code -Discrimination always reads `code` and never uses `instanceof`. Client and Host are separately bundled programs, and a worker transport bundles the page half once more, so several copies of the same class exist and prototype identity across copies does not hold. The mechanism layer reads the structural marker through the protocol's `remoteErrorOf(value)`, and the Gateway client face additionally exports `isRemoteFailure(error)` for a consumer's catch site; both read the marker, never the class. +Discrimination always reads `code` and never uses `instanceof`. Client and Host are separately bundled programs, and a worker transport bundles the page half once more, so several copies of the same class exist and prototype identity across copies does not hold. The mechanism layer reads the structural marker plus a string `code` through the protocol's `remoteErrorOf(value)`, and the Gateway client face additionally exports `isRemoteFailure(error)` for a consumer's catch site; both read those fields, never the class — the test does not even require `instanceof Error`, because an Error thrown in another realm fails that too. Business code usually needs neither function: the `ok: false` branch of `RemoteResult` is already a typed `RemoteFailure`, so `if (result.error.code === 'session/not-found')` narrows `details` to that code's shape with no cast. A site that must propagate the failure writes `throw result.error` — it is a real `Error`, with a working stack and `message`. diff --git a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md index c00aa87b30..6b75aae454 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md @@ -16,11 +16,11 @@ Host 固定事实同样绕过了 `ctx.remote`:Host home 取自 `(ctx.get('conn ## Decision -`@deepseek-ai/dsh-typert-protocol` 导出唯一的失败类 `RemoteError`:一个真 `Error`,带只读 `code` 与 `details`、结构标记 `isDSHRemoteGatewayError`,以及标准 `ErrorOptions`(`cause` 只在进程内有效)。码与 details 的对应关系收进一张 merge-extensible 的 `RemoteErrorDetailsMap`;`RemoteFailure` 是按码分布的实例 union,`RemoteResult` 形状不变。 +`@deepseek-ai/dsh-typert-protocol` 导出唯一的失败类 `RemoteError`:一个真 `Error`,带只读 `code` 与 `details`、结构标记 `isDSHRemoteError`,以及标准 `ErrorOptions`(`cause` 只在进程内有效)。码与 details 的对应关系收进一张 merge-extensible 的 `RemoteErrorDetailsMap`;`RemoteFailure` 是按码分布的实例 union,`RemoteResult` 形状不变。 ```text export class RemoteError extends Error { - readonly isDSHRemoteGatewayError = true as const + readonly isDSHRemoteError: true = true constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions) } @@ -41,11 +41,11 @@ export type RemoteResult = { ok: true; value: T } | { ok: false; error: Remot - **跨包共产**:两个及以上不同包抛同一个码时,声明落到双方都已依赖的最低层。`session/not-found` 落 `@deepseek-ai/dsh-session`(session-controller 与 workspace-controller 都依赖它),`workspace/not-found` 落 `@deepseek-ai/dsh-workspace`(session-controller 与 workspace-controller 之间没有依赖边,能力包是唯一共同下层)。 - **单一生产者**:只有一个包抛的码落生产者包。`subagent/not-found` 与 `agent-preset/conflict` 因此落 session-controller——全仓只有它抛这两个码,subagent 与 agent-presets 的码表里都没有它们。 -共享的是校验逻辑,不是码。`session/invalid-time-zone` 与 `subagent/invalid-time-zone` 是两个域各自声明、各自抛出的两个码,两个端点共用 `@deepseek-ai/dsh-time` 的 `canonicalClientTimeZone()` 做规范化;client 对这个码没有分支语义,拆码的成本是零,而合成一个码就会重新制造可达性问题。 +共享的是校验逻辑,不是码。`session/invalid-time-zone` 与 `subagent/invalid-time-zone` 是两个域各自声明、各自抛出的两个码,两个端点共用 `@deepseek-ai/dsh-util-time` 的 `canonicalClientTimeZone()` 做规范化;client 对这个码没有分支语义,拆码的成本是零,而合成一个码就会重新制造可达性问题。 ## Discrimination by code -判别一律读 `code`,从不用 `instanceof`。Client 与 Host 是两个独立打包的 program,worker 传输还会把页面侧再分一次包,因此同一个类会存在多份副本,跨副本的原型链身份不成立。机制层用 protocol 的 `remoteErrorOf(value)` 读结构标记,Gateway client face 另外导出 `isRemoteFailure(error)` 供消费方在 catch 里判别;两者都只看标记,不看类。 +判别一律读 `code`,从不用 `instanceof`。Client 与 Host 是两个独立打包的 program,worker 传输还会把页面侧再分一次包,因此同一个类会存在多份副本,跨副本的原型链身份不成立。机制层用 protocol 的 `remoteErrorOf(value)` 读结构标记加一个字符串 `code`,Gateway client face 另外导出 `isRemoteFailure(error)` 供消费方在 catch 里判别;两者都只看这两个字段、不看类——连 `instanceof Error` 都不要求,因为另一个 realm 抛出的 Error 同样通不过它。 业务代码通常连这两个函数都不需要:`RemoteResult` 的 `ok: false` 分支已经是类型化的 `RemoteFailure`,`if (result.error.code === 'session/not-found')` 就把 `details` 窄化到该码的形状,无需 cast。需要向上抛的站点直接 `throw result.error`——它是真 `Error`,栈与 `message` 都成立。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index c5feee2c91..c6d598ecbc 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 43b00bcff3da0adb1d53ac25534b65366bc42611 -api-gateway.zh.md: bb2711220fd75ef0119e1188c22a0af5ad4169a8 +api-gateway.md: 92d1bb5f0103fd4e2424992de9445f4768346f91 +api-gateway.zh.md: 3c3d95a5c125c592baf21579fb724101708b5cf8 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 43b00bcff3..92d1bb5f01 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -124,7 +124,7 @@ The Connection performs the unified trust check for `/api` before the HTTP bridg For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. -The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. +The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `gateway/lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard resolver semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. A resume failure and an ownership fence raise a `RemoteError` carrying their own code, `session/not-found` or `session/agent-busy`, which the Gateway encodes onto the wire unchanged; only an unclassified throw folds into `gateway/internal`. Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index bb2711220f..3c3d95a5c1 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -124,7 +124,7 @@ Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共 Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法并校验返回值。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 -lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 +lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `gateway/lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 resolver 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败与 ownership fence 抛出携带自有码的 `RemoteError`(`session/not-found` 或 `session/agent-busy`),Gateway 原样编码上 wire;只有未归类的 throw 才折成 `gateway/internal`。 Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的陈旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。