refactor(api): converge the Remote failure vocabulary and client surface

Single RemoteError with a merge-extensible, domain-prefixed code map;
owners throw at the failure point; streams surface marked failures;
clients consume ctx.remote directly with isRemoteFailure as the only
discrimination point and construct no failure instances.
This commit is contained in:
imccyu
2026-08-28 22:37:36 +08:00
parent 12d7b4ed0c
commit 804b1ffbfc
252 changed files with 3182 additions and 3832 deletions
+17 -26
View File
@@ -11,9 +11,9 @@ import type {} from '@deepseek-ai/dsh-agent-presets'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
import type { ModelSelection, SessionError } from './types.ts'
import type { ModelSelection } from './types.ts'
/** Cold Session identity absent from persistence. */
export class ApiSessionNotFound extends Error {}
@@ -57,10 +57,7 @@ export class ApiSessionPresetConflict extends Error {
}
/** Failures produced while resolving one ordinary Session identity to its live Agent. */
export type ApiSessionAgentError = Extract<
SessionError,
{ readonly code: 'session-not-found' | 'agent-busy' | 'internal' }
>
export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'gateway/internal'>
/** Result of resolving one ordinary Session identity to its live Agent. */
export type ApiSessionAgentResult =
@@ -97,11 +94,11 @@ export function hasApiSessionSubagentOwner(
* @returns a stable Session-domain failure.
*/
export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError {
return {
code: 'agent-busy',
message: `session "${sessionId}" is owned by subagent routing`,
details: { reason: 'use subagent delivery for this child session' },
}
return new RemoteError(
'session/agent-busy',
`session "${sessionId}" is owned by subagent routing`,
{ reason: 'use subagent delivery for this child session' },
)
}
/**
@@ -145,17 +142,17 @@ export class ApiSessionAgentController {
constructor(private readonly ctx: Context) {
ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw new TypertLookupFailure(found.error)
if ('error' in found) throw found.error
return found.agent
})
ctx.typert.lookups.configure('session', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw new TypertLookupFailure(found.error)
if ('error' in found) throw found.error
return found.agent.session
})
ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw new TypertLookupFailure(found.error)
if ('error' in found) throw found.error
return found.agent.ctx
})
}
@@ -198,13 +195,7 @@ export class ApiSessionAgentController {
return { agent: await resume }
} catch (error: unknown) {
if (error instanceof ApiSessionNotFound) {
return {
error: {
code: 'session-not-found',
message: error.message,
details: { sessionId },
},
}
return { error: new RemoteError('session/not-found', error.message, { sessionId }) }
}
if (error instanceof ApiSessionSubagentOwnership) {
return { error: apiSessionSubagentOwnershipError(error.sessionId) }
@@ -216,11 +207,11 @@ export class ApiSessionAgentController {
return { error: apiSessionSubagentOwnershipError(sessionId) }
}
return {
error: {
code: 'internal',
message: `resume failed for session "${sessionId}": ${String(error)}`,
details: {},
},
error: new RemoteError(
'gateway/internal',
`resume failed for session "${sessionId}": ${String(error)}`,
{},
),
}
}
}
@@ -1,29 +0,0 @@
/** Client operation results spanning the Session and subagent Remote calls. */
import type { RpcError } from '@deepseek-ai/dsh-client-connection/client'
import type { SubagentControlError } from '@deepseek-ai/dsh-subagent/client'
import type { SessionError } from '../../types.ts'
/** Failure surfaced by the Client Session object layer. */
export type ClientFailure = RpcError | SessionError | SubagentControlError
/** Success or failure returned by a Client Session operation. */
export type ClientResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: ClientFailure }
/**
* Fold a rejected carrier operation into the Client Session failure vocabulary.
* @param error - rejection from a Remote or local carrier call.
* @returns the failure branch of a Client Session result.
*/
export function transportResult<T>(error: unknown): ClientResult<T> {
return {
ok: false,
error: {
code: 'internal',
message: error instanceof Error ? error.message : String(error),
details: {},
},
}
}
@@ -13,7 +13,6 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
import type { ClientResult } from './result.ts'
import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts'
/**
@@ -84,7 +83,7 @@ export interface ISession {
mode: 'queue' | 'steer',
signal?: AbortSignal,
requestId?: SessionRequestId,
): Promise<ClientResult<{ accepted: true }>>
): Promise<RemoteResult<{ accepted: true }>>
/**
* Resolve one durable image referenced by this session.
* @param attachmentId - opaque id found in the folded session log.
@@ -92,27 +91,27 @@ export interface ISession {
*/
readAttachment(
attachmentId: AttachmentIdType,
): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
): Promise<RemoteResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
/**
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - requested queue operation.
* @returns acceptance, or a business/transport error.
*/
updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>>
updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{ accepted: true }>>
/**
* Cancel the running turn. Pending queued work remains and resumes in FIFO
* order after the Host reaches cancellation quiescence.
* @returns acceptance, or the business error.
*/
cancel(): Promise<ClientResult<{ accepted: true }>>
cancel(): Promise<RemoteResult<{ accepted: true }>>
/**
* Rename this session (explicit user title; pins it against automatic
* regeneration).
* @param title - raw title text (the host normalizes acceptance).
* @returns the normalized accepted title and its event seq, or the business error.
*/
rename(title: string): Promise<ClientResult<{ title: string; seq: number }>>
rename(title: string): Promise<RemoteResult<{ title: string; seq: number }>>
/**
* Extend the history window backwards (older messages pagination).
* @returns completion; failures land in snapshot.openState/loadingOlder.
@@ -8,10 +8,10 @@ import type { Context } from '@deepseek-ai/cordis'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { AgentContext } from '../scope.ts'
import type { SessionSearchResultItem } from '../sessions/manager.ts'
import type { SessionBinding, SessionListState } from '../sessions/service.ts'
import type { ClientResult } from './result.ts'
import type { SessionFace } from './session.ts'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -83,7 +83,7 @@ export interface ISessions {
search(
query: string,
signal: AbortSignal,
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
): Promise<RemoteResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.
@@ -3,8 +3,8 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionRequestId } from '../../types.ts'
import type { ClientFailure } from './result.ts'
/** One transient inbox occurrence from the authoritative queue snapshot. */
export interface QueuedMessage {
@@ -53,7 +53,7 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
/** Send/stop failure surfaced by Session consumers. */
export interface PromptError {
readonly op: 'send' | 'stop'
readonly error: ClientFailure
readonly error: RemoteFailure
}
/** Immutable Session lifecycle and control snapshot. */
@@ -70,7 +70,7 @@ export interface SessionSnapshot {
} | null
readonly removed: boolean
readonly openState: OpenState
readonly openError: ClientFailure | null
readonly openError: RemoteFailure | null
readonly hasMore: boolean
readonly loadingOlder: boolean
readonly promptError: PromptError | null
@@ -2,7 +2,6 @@
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent/types'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { createSessionControlStream } from './transport.ts'
import { ClientSessions } from './sessions/service.ts'
import type { SessionRemotes } from './sessions/remotes.ts'
@@ -13,7 +12,6 @@ export {
SessionEventStream,
SESSION_SEARCH_RESULT_LIMIT,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
sessionStreamFailure,
} from './transport.ts'
export type {
ClientSessionPageRequest,
@@ -66,7 +64,6 @@ export type {
QueuedMessage,
SessionSnapshot,
} from './contract/snapshot.ts'
export type { ClientFailure, ClientResult } from './contract/result.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -75,9 +72,8 @@ declare module '@deepseek-ai/cordis' {
}
}
/** Required wire, Remote, and Context projection services. */
/** Required Remote and Context projection services. */
export const inject = [
'connection',
'typert',
'remote',
'remote.commands',
@@ -90,7 +86,6 @@ export const inject = [
* @param ctx - Client Cordis context.
*/
export function apply(ctx: Context): void {
const connection = ctx.get('connection') as ConnectionHandle
const remotes = ctx.remote as unknown as SessionRemotes
const sessions = new ClientSessions(ctx, remotes)
ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
@@ -111,7 +106,7 @@ export function apply(ctx: Context): void {
})
control.start()
ctx.on('connection/reset', () => { sessions.handleConnected() })
if (connection.generation.getSnapshot() !== undefined) sessions.handleConnected()
if (ctx.remote.$host.home !== undefined) sessions.handleConnected()
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
resolve: sessionId => sessions.resolveAgentScope(sessionId),
@@ -9,13 +9,12 @@ import type {
SessionControlBaseline,
SessionControlFrame,
SessionQueuedItem,
SessionError,
SessionSummary,
SessionJob as JobView,
} from '../../types.ts'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import type { ClientFailure, ClientResult } from '../contract/result.ts'
import { transportResult } from '../contract/result.ts'
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
// Type-only merge edge: the title domain's client-namespace outlet declares
@@ -51,7 +50,7 @@ export interface SessionListSnapshot {
state: 'idle' | 'loading' | 'error'
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
phase: SessionListPhase
error: ClientFailure | null
error: RemoteFailure | null
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Background jobs per session; an absent key is an empty set. */
jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
@@ -63,7 +62,7 @@ export type SubagentCatalogSnapshot = Omit<SubagentCatalog, 'parentAvailable'> &
/** Absent until the first successful catalog read. */
readonly parentAvailable?: boolean
state: 'loading' | 'ready' | 'error'
error: ClientFailure | null
error: RemoteFailure | null
}
function catalogAvailability(parentAvailable: boolean | undefined): {
@@ -112,7 +111,7 @@ export class SessionManager {
private listState: 'idle' | 'loading' | 'error' = 'idle'
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
private listPhase: SessionListPhase = 'pending'
private listError: ClientFailure | null = null
private listError: RemoteFailure | null = null
private listInflight: Promise<void> | null = null
/** Mutations arriving after a list request starts are replayed over its response. */
private listMutations: SessionListMutation[] | null = null
@@ -366,7 +365,7 @@ export class SessionManager {
this.notifier.markDirty()
const operation = (async () => {
try {
const result = toSessionResult(await this.remote.subagents.list(parentSessionId))
const result = await this.remote.subagents.list(parentSessionId)
if (result.ok) {
const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? result.value.parentAvailable
@@ -395,7 +394,7 @@ export class SessionManager {
})
}
} catch (error: unknown) {
const folded = transportResult<never>(error)
if (!isRemoteFailure(error)) throw error
this.catalogs.set(parentSessionId, {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
@@ -405,7 +404,7 @@ export class SessionManager {
?? previous?.parentAvailable,
),
state: 'error',
error: folded.ok ? null : folded.error,
error,
})
} finally {
this.catalogInflight.delete(parentSessionId)
@@ -457,7 +456,7 @@ export class SessionManager {
this.notifier.markDirty()
this.listInflight = (async () => {
try {
const result = toSessionResult(await this.remote.session.list({}))
const result = await this.remote.session.list({})
if (result.ok) {
const baseline: SessionSummary[] = this.listPhase === 'pending'
? [...result.value.items]
@@ -506,10 +505,9 @@ export class SessionManager {
this.listError = result.error
}
} catch (error) {
if (!isRemoteFailure(error)) throw error
this.listState = 'error'
const folded = transportResult<never>(error)
/* v8 ignore next -- the `? null` arm is unreachable: transportResult always returns ok:false. */
this.listError = folded.ok ? null : folded.error
this.listError = error
} finally {
this.listMutations = null
this.listInflight = null
@@ -529,19 +527,15 @@ export class SessionManager {
async search(
query: string,
signal: AbortSignal,
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
try {
const result = toSessionResult(await this.remote.session.search({ query }, signal))
if (!result.ok) return result
return {
ok: true,
value: {
items: [...result.value.items],
hasMore: result.value.hasMore,
},
}
} catch (error: unknown) {
return transportResult(error)
): Promise<RemoteResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
const result = await this.remote.session.search({ query }, signal)
if (!result.ok) return result
return {
ok: true,
value: {
items: [...result.value.items],
hasMore: result.value.hasMore,
},
}
}
@@ -558,36 +552,32 @@ export class SessionManager {
cwd?: string
sessionId?: SessionId
} = {},
): Promise<ClientResult<{ sessionId: SessionId }>> {
try {
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
const payload = opts.workspaceId !== undefined
? { workspaceId: opts.workspaceId, ...shared }
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
const result = toSessionResult(await this.remote.session.create(payload))
if (result.ok) {
): Promise<RemoteResult<{ sessionId: SessionId }>> {
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
const payload = opts.workspaceId !== undefined
? { workspaceId: opts.workspaceId, ...shared }
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
const result = await this.remote.session.create(payload)
if (result.ok) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
} })
} else {
const publishedSessionId = workspaceAttachSessionId(result.error)
// Publication precedes attachment. The error's id is a real Session,
// so expose it immediately as Ungrouped while the caller keeps the
// prompt buffer and decides whether to retry attachment.
if (publishedSessionId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
sessionId: publishedSessionId,
updatedAt: Date.now(),
running: false,
blank: true,
} })
} else {
const publishedSessionId = workspaceAttachSessionId(result.error)
// Publication precedes attachment. The error's id is a real Session,
// so expose it immediately as Ungrouped while the caller keeps the
// prompt buffer and decides whether to retry attachment.
if (publishedSessionId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: publishedSessionId,
updatedAt: Date.now(),
running: false,
blank: true,
} })
}
}
return result
} catch (error) {
return transportResult(error)
}
return result
}
/**
@@ -601,27 +591,23 @@ export class SessionManager {
*/
async fork(
opts: { sessionId: SessionId; atSeq?: number },
): Promise<ClientResult<{ sessionId: SessionId }>> {
try {
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
const result = toSessionResult(await this.remote.session.fork({
sessionId: opts.sessionId,
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
}))
const childId = result.ok
? result.value.sessionId
: workspaceAttachSessionId(result.error)
if (childId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
parentSessionId: opts.sessionId,
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
} })
}
return result
} catch (error) {
return transportResult(error)
): Promise<RemoteResult<{ sessionId: SessionId }>> {
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
const result = await this.remote.session.fork({
sessionId: opts.sessionId,
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
})
const childId = result.ok
? result.value.sessionId
: workspaceAttachSessionId(result.error)
if (childId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
parentSessionId: opts.sessionId,
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
} })
}
return result
}
/**
@@ -1010,13 +996,6 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
}
/** Temporary source-plane bridge while the Host contract and client project build independently. */
function workspaceAttachSessionId(error: ClientFailure): SessionId | undefined {
return error.code === 'workspace-attach-failed' ? error.details.sessionId : undefined
}
/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */
function toSessionResult<T>(
result: import('@deepseek-ai/dsh-typert-protocol').RemoteResult<T>,
): ClientResult<T> {
return result.ok ? result : { ok: false, error: result.error as SessionError }
function workspaceAttachSessionId(error: RemoteFailure): SessionId | undefined {
return error.code === 'session/workspace-attach-failed' ? error.details.sessionId : undefined
}
@@ -25,7 +25,7 @@ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/t
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-store'
import type { ClientFailure, ClientResult } from '../contract/result.ts'
import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionEventSource } from '../contract/events.ts'
import type { SessionFace } from '../contract/session.ts'
import type { AgentContext, ISessions } from '../contract/sessions.ts'
@@ -101,7 +101,7 @@ export class SessionCreateError extends Error {
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
*/
constructor(
readonly rpcError: ClientFailure,
readonly rpcError: RemoteFailure,
readonly requestedSessionId: SessionId | undefined,
) {
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
@@ -117,7 +117,7 @@ export class SessionForkError extends Error {
* @param sourceSessionId - the session the fork was cut from.
*/
constructor(
readonly rpcError: ClientFailure,
readonly rpcError: RemoteFailure,
readonly sourceSessionId: SessionId,
) {
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
@@ -335,7 +335,7 @@ export class ClientSessions implements ISessions {
search(
query: string,
signal: AbortSignal,
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
): Promise<RemoteResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
return this.manager.search(query, signal)
}
@@ -6,10 +6,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
SessionEventStream,
sessionStreamFailure,
} from '../transport.ts'
import { SessionEventStream } from '../transport.ts'
import type { SessionJournalChange } from '../transport.ts'
import type {
PromptContentPart,
@@ -18,10 +15,7 @@ import type {
SessionControlFrame,
SessionQueuedItem,
SessionRequestId,
SessionError,
} from '../../types.ts'
import type { ClientFailure, ClientResult } from '../contract/result.ts'
import { transportResult } from '../contract/result.ts'
import type {
BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle,
} from '../contract/session.ts'
@@ -33,7 +27,8 @@ import type {
SessionEventLikeEntry, SessionLiveEventEntry,
} from '../contract/events.ts'
import { Notifier } from './notifier.ts'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionRemotes } from './remotes.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
@@ -77,7 +72,7 @@ export class Session implements SessionFace {
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
private openError: ClientFailure | null = null
private openError: RemoteFailure | null = null
private openPromise: Promise<void> | null = null
/** Bumped by stream replacement to invalidate an in-flight doOpen. Stale
* passes drop all writes once the generation moves on. */
@@ -214,7 +209,7 @@ export class Session implements SessionFace {
mode: 'queue' | 'steer',
signal?: AbortSignal,
requestId?: SessionRequestId,
): Promise<ClientResult<{ accepted: true }>> {
): Promise<RemoteResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
// Synchronous, before the first await: the blank → engaging edge must be
@@ -223,52 +218,26 @@ export class Session implements SessionFace {
this.promptAttempted = true
if (this.blankBit) this.firstPromptPendingTurn = true
this.notifier.markDirty()
let result: ClientResult<{ accepted: true }>
try {
if (this.address === undefined) {
const clientTimeZone = resolvedClientTimeZone()
result = toSessionResult(await this.remote.session.prompt({
requestId: requestId ?? randomUUID() as SessionRequestId,
sessionId: this.sessionId,
mode,
content,
clientTimeZone,
}, signal))
} else if (this.address.mode === 'one-shot') {
result = {
ok: false,
error: {
code: 'subagent-not-resumable',
message: 'one-shot subagent conversations are read-only',
details: { childSessionId: this.address.childSessionId },
},
}
} else {
if (content.some(part => part.type === 'image')) {
result = {
ok: false,
error: {
code: 'attachment-error',
message: 'Image input is unavailable for subagent continuations.',
details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
},
}
} else {
const routed = toSessionResult(await this.remote.subagents.prompt({
requestId: randomUUID() as SessionRequestId,
parentSessionId: this.address.parentSessionId,
childSessionId: this.address.childSessionId,
mode: this.address.mode,
content: content.flatMap(part => part.type === 'text'
? [{ type: 'text' as const, text: part.text }]
: []),
clientTimeZone: resolvedClientTimeZone(),
}, signal))
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
}
} catch (error) {
result = transportResult(error)
let result: RemoteResult<{ accepted: true }>
if (this.address === undefined) {
const clientTimeZone = resolvedClientTimeZone()
result = await this.remote.session.prompt({
requestId: requestId ?? randomUUID() as SessionRequestId,
sessionId: this.sessionId,
mode,
content,
clientTimeZone,
}, signal)
} else {
const routed = await this.remote.subagents.prompt({
requestId: randomUUID() as SessionRequestId,
parentSessionId: this.address.parentSessionId,
childSessionId: this.address.childSessionId,
mode: 'continuable',
content,
clientTimeZone: resolvedClientTimeZone(),
}, signal)
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
if (!result.ok) {
if (requestId !== undefined) this.retireFailedSubmission(requestId)
@@ -299,66 +268,38 @@ export class Session implements SessionFace {
*/
async readAttachment(
attachmentId: AttachmentIdType,
): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
try {
const result = await this.remote.session.attachment({
sessionId: this.sessionId,
attachmentId,
})
if (!result.ok) return toSessionResult(result)
const binary = atob(result.value.data)
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
return { ok: true, value: { attachment: result.value.attachment, data } }
} catch (error) {
return transportResult(error)
}
): Promise<RemoteResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
const result = await this.remote.session.attachment({
sessionId: this.sessionId,
attachmentId,
})
if (!result.ok) return result
const binary = atob(result.value.data)
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
return { ok: true, value: { attachment: result.value.attachment, data } }
}
/** Apply one operation to a still-pending queue occurrence. */
async updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>> {
try {
return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }))
} catch (error) {
return transportResult(error)
}
async updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{ accepted: true }>> {
return this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action })
}
/**
* Stop the active turn while the Host preserves pending inbox work; failures
* land in promptError (same error-strip display slot). A continuable
* subagent address routes through `subagents.interruptByParent`, whose durable
* parent-address authority works without a live parent Agent; a one-shot
* address stays uncancellable (the UI offers no stop action, so this arm is
* defensive).
* land in promptError (same error-strip display slot). A subagent address
* routes through `subagents.interruptByParent`, whose durable parent-address
* authority works without a live parent Agent.
* @returns the cancel result.
*/
async cancel(): Promise<ClientResult<{ accepted: true }>> {
async cancel(): Promise<RemoteResult<{ accepted: true }>> {
const address = this.address
if (address !== undefined && address.mode === 'one-shot') {
const result: ClientResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
return result
}
let result: ClientResult<{ accepted: true }>
try {
result = address !== undefined
? toSessionResult(await this.remote.subagents.interruptByParent(
address.childSessionId,
address.parentSessionId,
address.mode,
))
: toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId }))
} catch (error) {
result = transportResult(error)
}
const result = address !== undefined
? await this.remote.subagents.interruptByParent(
address.childSessionId,
address.parentSessionId,
'continuable',
)
: await this.remote.session.cancel({ sessionId: this.sessionId })
if (!result.ok) {
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
@@ -375,14 +316,10 @@ export class Session implements SessionFace {
* @param title - raw title text (the host normalizes acceptance).
* @returns the rename result (normalized accepted title + title event seq).
*/
async rename(title: string): Promise<ClientResult<{ title: string; seq: number }>> {
try {
const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title }))
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
return result
} catch (error) {
return transportResult(error)
}
async rename(title: string): Promise<RemoteResult<{ title: string; seq: number }>> {
const result = await this.remote.session.rename({ sessionId: this.sessionId, title })
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
return result
}
/**
@@ -390,7 +327,7 @@ export class Session implements SessionFace {
* admission semantics (the host executor durably logs the lifecycle;
* outcomes render as flow nodes, never as a response echo).
* @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure.
* @returns the admission result.
*/
async command(line: string): Promise<RemoteResult<{ matched: boolean }>> {
const result = await this.remote.commands.execute(this.sessionId, line, [])
@@ -420,7 +357,7 @@ export class Session implements SessionFace {
try {
await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
} catch (error) {
if (sessionStreamFailure(error) === undefined) {
if (!isRemoteFailure(error)) {
console.error('[session-controller] loadOlder failed:', error)
}
} finally {
@@ -600,9 +537,10 @@ export class Session implements SessionFace {
this.openState = 'open'
} catch (error) {
if (generation !== this.openGeneration || this.events !== events) return
if (!isRemoteFailure(error)) throw error
this.events = undefined
this.openState = 'error'
this.openError = openFailure(error)
this.openError = error
} finally {
if (generation === this.openGeneration) this.notifier.markDirty()
}
@@ -714,11 +652,12 @@ export class Session implements SessionFace {
/** Publish a terminal background failure only while this stream still owns the Session. */
private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
if (generation !== this.openGeneration || this.events !== events) return
if (!isRemoteFailure(error)) throw error
this.openGeneration++
this.events = undefined
this.openPromise = null
this.openState = 'error'
this.openError = openFailure(error)
this.openError = error
void events.dispose()
this.notifier.markDirty()
}
@@ -774,17 +713,3 @@ function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] {
}
return refs
}
/** Convert a terminal Session stream failure to the Client error vocabulary. */
function openFailure(error: unknown): ClientFailure {
const failure = sessionStreamFailure(error)
if (failure !== undefined) return failure as SessionError
const folded = transportResult<never>(error)
/* v8 ignore next -- transportResult never returns an ok result. */
if (folded.ok) throw new Error('transportResult returned an unexpected success')
return folded.error
}
/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */
function toSessionResult<T>(result: RemoteResult<T>): ClientResult<T> {
return result.ok ? result : { ok: false, error: result.error as SessionError }
}
@@ -1,12 +1,10 @@
/** Session-specific adapters for Gateway-owned Remote stream lifecycles. */
import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import {
RemoteJournalStream,
RemoteSnapshotStream,
RemoteStreamCarrierError,
RemoteStreamError,
type ClientRemote,
type RemoteJournalChange,
type RemoteJournalFrame,
@@ -25,6 +23,7 @@ import {
historyRecordLastSeq,
} from './sessions/history-records.ts'
import type { SessionEventLikeEntry, SessionLiveEventEntry } from './contract/events.ts'
import type { SessionRemotes } from './sessions/remotes.ts'
export {
SESSION_SEARCH_RESULT_LIMIT,
@@ -80,8 +79,6 @@ export type SessionControlStream = RemoteSnapshotStream<
SessionControlDeltaFrame
>
type SessionStreamRemote = Pick<ClientRemote, '$stream' | 'session'>
/** Domain sinks used by the Host-wide Session control stream. */
export interface SessionControlStreamOptions {
/** Apply a complete baseline or one later update. */
@@ -109,7 +106,7 @@ export interface SessionEventStreamOptions {
* @returns an unstarted stream owned by the Client Session runtime.
*/
export function createSessionControlStream(
remote: SessionStreamRemote,
remote: SessionRemotes,
options: SessionControlStreamOptions,
): SessionControlStream {
const stream = remote.$stream<SessionControlFrame>({
@@ -142,7 +139,7 @@ export class SessionEventStream extends RemoteJournalStream<
* @param options - Session event-window destinations.
*/
constructor(
private readonly remote: SessionStreamRemote,
private readonly remote: SessionRemotes,
private readonly address: SessionAddress,
options: SessionEventStreamOptions,
) {
@@ -198,13 +195,7 @@ export class SessionEventStream extends RemoteJournalStream<
{ address: this.address, throughSeq, ...request },
signal,
)
if (!result.ok) {
throw new RemoteStreamError(
result.error.code,
result.error.message,
result.error.details,
)
}
if (!result.ok) throw result.error
return result.value
}
@@ -215,13 +206,3 @@ export class SessionEventStream extends RemoteJournalStream<
return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }
}
}
/**
* Recover a Host Session failure from a Remote stream terminal error.
* @param error - value thrown while opening or consuming a Session stream.
* @returns the Host failure, or `undefined` for carrier and local failures.
*/
export function sessionStreamFailure(error: unknown): RemoteFailure | undefined {
if (!(error instanceof RemoteStreamError)) return undefined
return { code: error.code, message: error.message, details: error.details }
}
+56 -87
View File
@@ -3,7 +3,6 @@
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import {
@@ -14,7 +13,8 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import {
ApiSessionAgentController,
@@ -71,14 +71,14 @@ export class SessionCommandController {
*/
async create(request: SessionCreateRequest): Promise<SessionCreateValue> {
if (request.workspaceId !== undefined && request.cwd !== undefined) {
reject('bad-request', 'session.create accepts workspaceId or cwd, not both', {})
throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {})
}
const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`)
let workspace: Workspace | undefined
if (request.workspaceId !== undefined) {
workspace = this.ctx.workspaceRegistry.get(request.workspaceId)
if (workspace === undefined) {
reject('workspace-not-found', `workspace "${request.workspaceId}" not found`, {
throw new RemoteError('workspace/not-found', `workspace "${request.workspaceId}" not found`, {
workspaceId: request.workspaceId,
})
}
@@ -99,8 +99,8 @@ export class SessionCommandController {
try {
await workspace.attachSession(sessionId)
} catch (error) {
reject(
'workspace-attach-failed',
throw new RemoteError(
'session/workspace-attach-failed',
`session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
{ sessionId, workspaceId: workspace.id },
)
@@ -143,9 +143,9 @@ export class SessionCommandController {
}
return { selected: { ...selected } }
} catch (error) {
if (error instanceof TypertRemoteFailure) throw error
reject(
'model-unavailable',
if (remoteErrorOf(error) !== undefined) throw error
throw new RemoteError(
'session/model-unavailable',
error instanceof Error ? error.message : String(error),
{ provider: request.provider, model: request.model },
)
@@ -162,17 +162,17 @@ export class SessionCommandController {
const agent = await this.resolveAgent(request.sessionId)
const titles = this.ctx.get('sessionTitle')
if (titles === undefined) {
reject('internal', 'renaming is unavailable: this deployment mounts no session-title service', {})
throw new RemoteError('gateway/internal', 'renaming is unavailable: this deployment mounts no session-title service', {})
}
try {
const accepted = titles.rename(agent.session, request.title)
return { title: accepted.title, seq: accepted.eventSeq }
} catch (error) {
if (error instanceof SessionTitleInvalidError) {
reject('title-invalid', error.message, { sessionId: request.sessionId })
throw new RemoteError('session/title-invalid', error.message, { sessionId: request.sessionId })
}
reject(
'internal',
throw new RemoteError(
'gateway/internal',
`failed to rename session "${request.sessionId}": ${String(error)}`,
{},
)
@@ -187,7 +187,7 @@ export class SessionCommandController {
async fork(request: SessionForkRequest): Promise<SessionForkValue> {
if (request.atSeq !== undefined
&& (!Number.isInteger(request.atSeq) || request.atSeq < 0)) {
reject('bad-request', 'atSeq must be a non-negative integer', {})
throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative integer', {})
}
let observed: SessionObservation
try {
@@ -195,12 +195,12 @@ export class SessionCommandController {
} catch (error) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
reject('session-not-found', `session "${request.sessionId}" not found`, {
throw new RemoteError('session/not-found', `session "${request.sessionId}" not found`, {
sessionId: request.sessionId,
})
}
reject(
'internal',
throw new RemoteError(
'gateway/internal',
`fork source unavailable for session "${request.sessionId}": ${String(error)}`,
{},
)
@@ -216,8 +216,8 @@ export class SessionCommandController {
? source.events.findLast(event => event.type === 'turn/end')
: undefined)
if (boundary === undefined) {
reject(
'fork-unavailable',
throw new RemoteError(
'session/fork-unavailable',
atSeq !== undefined && atSeq <= lastSeq
? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}`
: `session "${request.sessionId}" has no completed turn to fork from`,
@@ -230,8 +230,8 @@ export class SessionCommandController {
try {
workspace = await this.forkWorkspace(source.header)
} catch (error) {
reject(
'internal',
throw new RemoteError(
'gateway/internal',
`failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`,
{},
)
@@ -255,8 +255,8 @@ export class SessionCommandController {
setup: composition.setup,
})
} catch (error) {
reject(
'internal',
throw new RemoteError(
'gateway/internal',
`failed to fork session "${request.sessionId}": ${String(error)}`,
{},
)
@@ -265,8 +265,8 @@ export class SessionCommandController {
try {
await workspace.attachSession(childId)
} catch (error) {
reject(
'workspace-attach-failed',
throw new RemoteError(
'session/workspace-attach-failed',
`session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
{ sessionId: childId, workspaceId: workspace.id },
)
@@ -285,8 +285,8 @@ export class SessionCommandController {
? undefined
: canonicalClientTimeZone(request.clientTimeZone)
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
reject(
'invalid-time-zone',
throw new RemoteError(
'session/invalid-time-zone',
'clientTimeZone must be UTC or a valid IANA Area/Location name',
{ value: request.clientTimeZone },
)
@@ -294,8 +294,8 @@ export class SessionCommandController {
const agent = await this.resolveAgent(request.sessionId)
const selection = this.agents.selectionFor(agent).current
if (!routeServed(this.ctx, selection.provider)) {
reject(
'model-unavailable',
throw new RemoteError(
'session/model-unavailable',
`no adapter serves provider "${selection.provider}"; select a model for this session`,
{ provider: selection.provider, model: selection.model },
)
@@ -312,8 +312,8 @@ export class SessionCommandController {
const current = this.agents.selectionFor(agent).current
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
reject(
'attachment-error',
throw new RemoteError(
'session/attachment-invalid',
`Model "${current.model}" does not support image input.`,
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
)
@@ -324,11 +324,11 @@ export class SessionCommandController {
if (request.mode === 'steer') agent.steer(message)
else agent.followup(message)
} catch (error) {
if (error instanceof TypertRemoteFailure) throw error
if (remoteErrorOf(error) !== undefined) throw error
if (error instanceof AttachmentError) {
reject('attachment-error', error.message, { reason: error.code })
throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
}
reject('agent-busy', 'prompt rejected', { reason: String(error) })
throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) })
}
return { accepted: true }
}
@@ -346,18 +346,18 @@ export class SessionCommandController {
source = await this.readSessionState(request.sessionId)
} catch (error) {
if (error instanceof ApiSessionNotFound) {
reject('session-not-found', error.message, { sessionId: request.sessionId })
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId })
}
reject(
'internal',
throw new RemoteError(
'gateway/internal',
`attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`,
{},
)
}
const ref = referencedImage(source.events, String(request.attachmentId))
if (ref === undefined) {
reject(
'attachment-error',
throw new RemoteError(
'session/attachment-invalid',
'Image is not referenced by this session.',
{ reason: 'ATTACHMENT_NOT_REFERENCED' },
)
@@ -370,9 +370,9 @@ export class SessionCommandController {
}
} catch (error) {
if (error instanceof AttachmentError) {
reject('attachment-error', error.message, { reason: error.code })
throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
}
reject('internal', 'Unable to read image attachment.', {})
throw new RemoteError('gateway/internal', 'Unable to read image attachment.', {})
}
}
@@ -384,18 +384,18 @@ export class SessionCommandController {
updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
if (request.action.kind === 'edit'
&& request.action.content.some(block => block.type !== 'text')) {
reject(
'attachment-error',
throw new RemoteError(
'session/attachment-invalid',
'queue edits accept text content only',
{ reason: 'QUEUE_EDIT_NON_TEXT' },
)
}
const agent = this.ctx.agents.get(request.sessionId)
if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
throw apiSessionSubagentOwnershipError(request.sessionId)
}
if (agent === undefined) {
reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
}
const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
@@ -403,11 +403,11 @@ export class SessionCommandController {
? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep }
: { target: 'next-turn' as const, message: nextTurn }
if (located === undefined) {
reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
}
const { target, message } = located
if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
reject('steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
}
if (request.action.kind === 'edit') {
agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
@@ -429,14 +429,14 @@ export class SessionCommandController {
cancel(request: SessionCancelRequest): SessionCancelValue {
const agent = this.ctx.agents.get(request.sessionId)
if (agent === undefined) {
reject(
'session-not-found',
throw new RemoteError(
'session/not-found',
`session "${request.sessionId}" not found (not attached)`,
{ sessionId: request.sessionId },
)
}
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
throw apiSessionSubagentOwnershipError(request.sessionId)
}
agent.cancel({ kind: 'user' }, { keepInbox: true })
return { accepted: true }
@@ -444,41 +444,30 @@ export class SessionCommandController {
private async resolveAgent(sessionId: SessionId): Promise<Agent> {
const found = await this.agents.resolveAgent(sessionId)
if ('error' in found) rejectFailure(found.error)
if ('error' in found) throw found.error
return found.agent
}
private rejectCreation(sessionId: SessionId, error: unknown): never {
if (remoteErrorOf(error) !== undefined) throw error
if (error instanceof ApiSessionPresetConflict) {
reject('agent-preset-conflict', error.message, {
throw new RemoteError('agent-preset/conflict', error.message, {
sessionId: error.sessionId,
requestedPreset: error.requestedPreset,
...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }),
})
}
if (error instanceof UnknownPresetError) {
reject('agent-preset-not-found', error.message, {
agentPreset: error.presetId,
available: [...error.available],
})
}
if (error instanceof PresetMountError) {
reject('agent-preset-invalid', error.message, {
agentPreset: error.presetId,
reason: error.reason,
})
}
if (error instanceof ApiSessionCwdConflict) {
reject('session-conflict', error.message, {
throw new RemoteError('session/conflict', error.message, {
sessionId: error.sessionId,
requestedCwd: error.requestedCwd,
...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }),
})
}
if (error instanceof ApiSessionSubagentOwnership) {
rejectFailure(apiSessionSubagentOwnershipError(error.sessionId))
throw apiSessionSubagentOwnershipError(error.sessionId)
}
reject('internal', `failed to create session "${sessionId}": ${String(error)}`, {})
throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {})
}
private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
@@ -503,14 +492,6 @@ export class SessionCommandController {
}
}
function rejectFailure(error: { readonly code: string; readonly message: string; readonly details: object }): never {
throw new TypertRemoteFailure(error)
}
function reject(code: string, message: string, details: object): never {
throw new TypertRemoteFailure({ code, message, details })
}
async function durablePromptContent(
ctx: Context,
content: readonly SessionPromptRequest['content'][number][],
@@ -580,18 +561,6 @@ function referencedImage(
return undefined
}
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
function canonicalClientTimeZone(value: string): string | undefined {
if (value.length === 0 || value.trim() !== value
|| (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
try {
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone
} catch {
return undefined
}
}
function routeServed(ctx: Context, provider: string): boolean {
return ctx.llm.listProviders().some(entry => entry.id === provider)
}
+16 -20
View File
@@ -6,7 +6,7 @@ import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-sessi
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import type {} from '@deepseek-ai/dsh-subagent'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type {
SessionAddress,
SessionChunkRun,
@@ -55,15 +55,15 @@ export class SessionHistoryController {
const sourceLog = source.events
const sourceCursor = sourceLog.at(-1)?.seq ?? -1
if (request.throughSeq > sourceCursor) {
reject(
'bad-request',
throw new RemoteError(
'gateway/bad-request',
`session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`,
{},
)
}
/* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */
if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) {
reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
}
const page = paginate(
sourceLog,
@@ -155,7 +155,7 @@ export class SessionHistoryController {
}
if (item.seq < nextSeq) continue
if (item.seq !== nextSeq) {
reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
yield entryFor(item)
@@ -211,22 +211,22 @@ function projectionBlock(
function validatePageRequest(request: SessionPageRequest): void {
if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) {
reject('bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
}
if (request.beforeSeq !== undefined
&& (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) {
reject('bad-request', 'beforeSeq must be a non-negative safe integer', {})
throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {})
}
if (request.maxMessages !== undefined
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
reject('bad-request', 'maxMessages must be a positive safe integer', {})
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
}
}
function validateFollowRequest(request: SessionFollowRequest): void {
if (request.maxMessages !== undefined
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
reject('bad-request', 'maxMessages must be a positive safe integer', {})
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
}
}
@@ -241,34 +241,34 @@ function validateAddress(
): void {
if (address.kind === 'session') {
if (header.origin === 'subagent') {
reject('agent-busy', 'subagent Sessions require their durable parent address', {
throw new RemoteError('session/agent-busy', 'subagent Sessions require their durable parent address', {
reason: 'use subagent delivery for this child session',
})
}
return
}
if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) {
reject('subagent-unauthorized', 'subagent does not belong to the supplied parent', {
throw new RemoteError('subagent/unauthorized', 'subagent does not belong to the supplied parent', {
childSessionId: address.childSessionId,
})
}
const identity = projections?.values.subagent
if (identity === null) {
reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', {
throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is corrupt', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
reason: 'corrupt',
})
}
if (identity === undefined || identity.seq < (header.seedLength ?? 0)) {
reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', {
throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
reason: 'unsupported',
})
}
if (identity.mode !== address.mode) {
reject('subagent-unauthorized', 'subagent mode does not match the supplied address', {
throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', {
childSessionId: address.childSessionId,
})
}
@@ -276,18 +276,14 @@ function validateAddress(
function rejectNotFound(address: SessionAddress): never {
if (address.kind === 'session') {
reject('session-not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
throw new RemoteError('session/not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
}
reject('subagent-not-found', 'subagent is unavailable', {
throw new RemoteError('subagent/not-found', 'subagent is unavailable', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
})
}
function reject(code: string, message: string, details: object): never {
throw new TypertRemoteFailure({ code, message, details })
}
function paginate(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
+13 -17
View File
@@ -6,7 +6,7 @@ import { errorChain } from '@deepseek-ai/dsh-llm'
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import {
ApiSessionAgentController,
inspectApiSession,
@@ -264,7 +264,7 @@ export class SessionController extends TypertRemoteService {
* @param request - path after best-effort Session workspace resolution.
* @param signal - caller lifetime; abort terminates the native command.
* @returns confirmation after the native opener accepts the path.
* @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails.
* @throws RemoteError when the request is invalid, cancelled, or the opener fails.
*/
@Remote('openWorkspacePath')
async openWorkspacePath(
@@ -272,27 +272,23 @@ export class SessionController extends TypertRemoteService {
signal: AbortSignal,
): Promise<SessionOpenWorkspacePathValue> {
if (request.path.length === 0) {
throw new TypertRemoteFailure({
code: 'bad-request',
message: 'session.openWorkspacePath requires a non-empty path',
details: {},
})
throw new RemoteError(
'gateway/bad-request',
'session.openWorkspacePath requires a non-empty path',
{},
)
}
signal.throwIfAborted()
try {
await this.openPath(request.path, signal)
return { opened: true }
} catch (error: unknown) {
if (signal.aborted) {
throw new TypertRemoteFailure({
code: 'cancelled', message: 'path open was aborted', details: {},
})
}
throw new TypertRemoteFailure({
code: 'internal',
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {})
throw new RemoteError(
'gateway/internal',
`path open failed: ${error instanceof Error ? error.message : String(error)}`,
{},
)
}
}
+9 -13
View File
@@ -8,7 +8,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { z } from 'zod'
import {
SESSION_SEARCH_RESULT_LIMIT,
@@ -226,8 +226,8 @@ export class ApiSessionList {
signal.throwIfAborted()
const provider = this.ctx.get('sessionQuery')
if (provider === undefined) {
reject(
'internal',
throw new RemoteError(
'gateway/internal',
'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
{},
)
@@ -317,9 +317,9 @@ export class ApiSessionList {
} catch (error: unknown) {
signal.throwIfAborted()
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') {
reject('cancelled', 'session search was aborted', {})
throw new RemoteError('gateway/cancelled', 'session search was aborted', {})
}
reject('internal', `session search failed: ${String(error)}`, {})
throw new RemoteError('gateway/internal', `session search failed: ${String(error)}`, {})
}
}
@@ -351,25 +351,21 @@ export class ApiSessionList {
function normalizeSearchQuery(query: string): string {
const normalized = query.trim()
if (normalized.length === 0) {
reject('bad-request', 'session search query must not be empty', {})
throw new RemoteError('gateway/bad-request', 'session search query must not be empty', {})
}
if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) {
reject(
'bad-request',
throw new RemoteError(
'gateway/bad-request',
`session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`,
{},
)
}
if (normalized.includes('\0')) {
reject('bad-request', 'session search query must not contain NUL', {})
throw new RemoteError('gateway/bad-request', 'session search query must not contain NUL', {})
}
return normalized
}
function reject(code: string, message: string, details: object): never {
throw new TypertRemoteFailure({ code, message, details })
}
function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number {
return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
}
@@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type { SkillListRequest, SkillListValue } from './types.ts'
declare module '@deepseek-ai/cordis' {
@@ -30,7 +30,7 @@ export class SessionSkillCatalog extends TypertRemoteService {
* @param request - Session identity whose cwd and preset select the catalog view.
* @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics.
* @returns user-invocable skill metadata without loading skill bodies.
* @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it.
* @throws RemoteError when the Session cannot be inspected or no registry can serve it.
*/
@Remote
async list(request: SkillListRequest, signal: AbortSignal): Promise<SkillListValue> {
@@ -48,19 +48,16 @@ export class SessionSkillCatalog extends TypertRemoteService {
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
throw failure(
'session-not-found',
`session "${sessionId}" not found`,
{ sessionId },
)
throw new RemoteError('session/not-found', `session "${sessionId}" not found`, { sessionId })
}
throw failure(
'internal',
throw new RemoteError(
'gateway/internal',
`session "${sessionId}" could not be inspected: ${String(error)}`,
{},
)
}
if (cwd === undefined) {
throw failure('internal', `session "${sessionId}" has no project cwd`)
throw new RemoteError('gateway/internal', `session "${sessionId}" has no project cwd`, {})
}
const live = this.ctx.agents.get(sessionId)
@@ -68,9 +65,10 @@ export class SessionSkillCatalog extends TypertRemoteService {
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
const skillRegistry = scoped ?? this.ctx.get('skills')
if (skillRegistry === undefined) {
throw failure(
'internal',
throw new RemoteError(
'gateway/internal',
'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill',
{},
)
}
@@ -86,7 +84,7 @@ export class SessionSkillCatalog extends TypertRemoteService {
})),
}
} catch (error: unknown) {
throw failure('internal', `skill listing failed: ${String(error)}`)
throw new RemoteError('gateway/internal', `skill listing failed: ${String(error)}`, {})
}
}
@@ -108,13 +106,4 @@ export class SessionSkillCatalog extends TypertRemoteService {
}
}
/** Build one stable Remote failure with optional typed details. */
function failure(
code: 'session-not-found' | 'internal',
message: string,
details: { readonly sessionId: SessionId } | Record<never, never> = {},
): TypertRemoteFailure {
return new TypertRemoteFailure({ code, message, details })
}
export default SessionSkillCatalog
+30 -46
View File
@@ -174,55 +174,39 @@ export const SESSION_SEARCH_RESULT_LIMIT = 20
/** Maximum search snippet length in Unicode code points. */
export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
/** Error details returned by Session Remote methods. */
export interface SessionErrorDetailsMap {
'bad-request': Record<never, never>
cancelled: Record<never, never>
'session-not-found': { readonly sessionId: SessionId }
'model-unavailable': { readonly provider: string; readonly model: string }
'session-conflict': {
readonly sessionId: SessionId
readonly requestedCwd: string
readonly existingCwd?: string
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
'session/model-unavailable': { readonly provider: string; readonly model: string }
'session/conflict': {
readonly sessionId: SessionId
readonly requestedCwd: string
readonly existingCwd?: string
}
'session/agent-busy': { readonly reason: string }
'session/invalid-time-zone': { readonly value: string }
'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
'agent-preset/conflict': {
readonly sessionId: SessionId
readonly requestedPreset: string
readonly existingPreset?: string
}
'session/attachment-invalid': { readonly reason: string }
'session/queue-item-not-found': { readonly itemId: MessageId }
'session/steer-unavailable': { readonly itemId: MessageId }
'session/title-invalid': { readonly sessionId: SessionId }
'session/fork-unavailable': { readonly sessionId: SessionId }
'subagent/not-found': {
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
}
'subagent/catalog-diagnostic': {
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
}
'invalid-time-zone': { readonly value: string }
'workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
'workspace-not-found': { readonly workspaceId: string }
'agent-preset-conflict': {
readonly sessionId: SessionId
readonly requestedPreset: string
readonly existingPreset?: string
}
'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] }
'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string }
'agent-busy': { readonly reason: string }
'attachment-error': { readonly reason: string }
'queue-item-not-found': { readonly itemId: MessageId }
'steer-unavailable': { readonly itemId: MessageId }
'title-invalid': { readonly sessionId: SessionId }
'fork-unavailable': { readonly sessionId: SessionId }
'subagent-not-found': {
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
}
'subagent-catalog-diagnostic': {
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
'subagent-unauthorized': { readonly childSessionId: SessionId }
internal: Record<never, never>
}
/** Session business failure returned without throwing a carrier error. */
export type SessionError = {
[Code in keyof SessionErrorDetailsMap]: {
readonly code: Code
readonly message: string
readonly details: SessionErrorDetailsMap[Code]
}
}[keyof SessionErrorDetailsMap]
/** Session-addressed request for the human-invocable skill catalog. */
export interface SkillListRequest {
readonly sessionId: SessionId