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
@@ -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 }
}