mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user