>>()
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