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:
@@ -99,6 +99,7 @@
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-time": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
@@ -137,6 +138,7 @@
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-time": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,6 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
@@ -154,7 +153,7 @@ describe('ApiSession Agent lookup and recovery', () => {
|
||||
header: header('observed-without-cwd', null),
|
||||
} as SessionObservation
|
||||
await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({
|
||||
error: { code: 'session-not-found' },
|
||||
error: { code: 'session/not-found' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,7 +169,7 @@ describe('ApiSession Agent lookup and recovery', () => {
|
||||
if (host === undefined) throw new Error('Agent Context resolver was not registered')
|
||||
|
||||
await expect(host.resolve(live.id)).resolves.toBe(live.ctx)
|
||||
await expect(host.resolve(SessionId('missing'))).rejects.toBeInstanceOf(TypertLookupFailure)
|
||||
await expect(host.resolve(SessionId('missing'))).rejects.toMatchObject({ code: 'session/not-found' })
|
||||
})
|
||||
|
||||
it('returns raced ordinary Agents and ownership failures after resume throws', async () => {
|
||||
@@ -200,7 +199,7 @@ describe('ApiSession Agent lookup and recovery', () => {
|
||||
throw new Error('raced child publication')
|
||||
})
|
||||
await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
|
||||
error: { code: 'agent-busy' },
|
||||
error: { code: 'session/agent-busy' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -211,7 +210,7 @@ describe('ApiSession Agent lookup and recovery', () => {
|
||||
inspect: vi.fn(),
|
||||
})
|
||||
await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({
|
||||
error: { code: 'session-not-found' },
|
||||
error: { code: 'session/not-found' },
|
||||
})
|
||||
|
||||
const failed = await harness()
|
||||
@@ -222,7 +221,7 @@ describe('ApiSession Agent lookup and recovery', () => {
|
||||
})
|
||||
vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
|
||||
await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
||||
error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string },
|
||||
error: { code: 'gateway/internal', message: expect.stringContaining('factory unavailable') as string },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -408,7 +407,7 @@ describe('ApiSession create or adoption', () => {
|
||||
mount: () => Promise.resolve(),
|
||||
} as never)
|
||||
await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
|
||||
error: { code: 'agent-busy' },
|
||||
error: { code: 'session/agent-busy' },
|
||||
})
|
||||
|
||||
const conflict = await harness()
|
||||
|
||||
@@ -63,12 +63,14 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
|
||||
registerGenerationSource: () => () => {},
|
||||
start: () => ({ stop: () => {} }),
|
||||
}
|
||||
ctx.reflect.provide('connection', connection)
|
||||
ctx.reflect.provide('remote', {
|
||||
...remote,
|
||||
$stream: <Item>(options: RemoteStreamOptions<Item>) => (
|
||||
new RemoteStream(connection, options)
|
||||
),
|
||||
get $host() {
|
||||
return { home: generation?.host.home, isLoopback: connection.isLoopback }
|
||||
},
|
||||
$on: (event: string, listener: RemoteListener) => {
|
||||
const eventListeners = listeners.get(event) ?? new Set<RemoteListener>()
|
||||
eventListeners.add(listener)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MutableSessionEventSource, type SessionLiveEventEntry,
|
||||
} from '../src/client/contract/events.ts'
|
||||
import { transportResult } from '../src/client/contract/result.ts'
|
||||
|
||||
function entry(seq: number): SessionLiveEventEntry {
|
||||
return {
|
||||
@@ -76,14 +75,4 @@ describe('Client Session contracts', () => {
|
||||
expect(iterate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('folds Error and non-Error carrier rejections into Client failures', () => {
|
||||
expect(transportResult(new Error('transport unavailable'))).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'transport unavailable', details: {} },
|
||||
})
|
||||
expect(transportResult(404)).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: '404', details: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { PresetMountError } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
@@ -14,7 +15,7 @@ import { SessionCommandController } from '../src/commands.ts'
|
||||
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
|
||||
|
||||
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
|
||||
await expect(operation).rejects.toMatchObject({ failure: { code } })
|
||||
await expect(operation).rejects.toMatchObject({ code })
|
||||
}
|
||||
|
||||
function controllerAgents(overrides: object = {}): ApiSessionAgentController {
|
||||
@@ -76,7 +77,7 @@ describe('Session creation failures', () => {
|
||||
)
|
||||
await expectFailure(missingController.create({
|
||||
workspaceId: 'missing' as WorkspaceId,
|
||||
}), 'workspace-not-found')
|
||||
}), 'workspace/not-found')
|
||||
await missing.fiber.dispose()
|
||||
|
||||
const failed = await baseContext()
|
||||
@@ -97,26 +98,30 @@ describe('Session creation failures', () => {
|
||||
await expectFailure(failedController.create({
|
||||
sessionId: SessionId('workspace-session'),
|
||||
workspaceId: workspace.id,
|
||||
}), 'workspace-attach-failed')
|
||||
}), 'session/workspace-attach-failed')
|
||||
await failed.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
error: new PresetMountError('broken', 'invalid composition'),
|
||||
code: 'agent-preset-invalid',
|
||||
error: new RemoteError(
|
||||
'agent-preset/invalid',
|
||||
'agent-presets: preset "broken" failed to mount: invalid composition',
|
||||
{ agentPreset: 'broken', reason: 'invalid composition' },
|
||||
),
|
||||
code: 'agent-preset/invalid',
|
||||
},
|
||||
{
|
||||
error: new ApiSessionCwdConflict(SessionId('cwd-less'), '/requested', undefined),
|
||||
code: 'session-conflict',
|
||||
code: 'session/conflict',
|
||||
},
|
||||
{
|
||||
error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'),
|
||||
code: 'session-conflict',
|
||||
code: 'session/conflict',
|
||||
},
|
||||
{
|
||||
error: new Error('factory unavailable'),
|
||||
code: 'internal',
|
||||
code: 'gateway/internal',
|
||||
},
|
||||
])('maps $code creation failures', async ({ error, code }) => {
|
||||
const ctx = await baseContext()
|
||||
@@ -140,7 +145,7 @@ describe('Session creation failures', () => {
|
||||
await expectFailure(controller.create({
|
||||
workspaceId: 'workspace-1' as WorkspaceId,
|
||||
cwd: '/workspace',
|
||||
}), 'bad-request')
|
||||
}), 'gateway/bad-request')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -179,7 +184,7 @@ describe('Session fork failures', () => {
|
||||
)
|
||||
await expectFailure(unavailableController.fork({
|
||||
sessionId: SessionId('missing'),
|
||||
}), 'session-not-found')
|
||||
}), 'session/not-found')
|
||||
await withoutPersistence.fiber.dispose()
|
||||
|
||||
const missing = await baseContext()
|
||||
@@ -191,7 +196,7 @@ describe('Session fork failures', () => {
|
||||
const missingController = new SessionCommandController(missing, controllerAgents(), '/default')
|
||||
await expectFailure(missingController.fork({
|
||||
sessionId: SessionId('missing'),
|
||||
}), 'session-not-found')
|
||||
}), 'session/not-found')
|
||||
await missing.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -201,7 +206,7 @@ describe('Session fork failures', () => {
|
||||
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
|
||||
|
||||
await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal')
|
||||
await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -211,7 +216,7 @@ describe('Session fork failures', () => {
|
||||
const source = ctx.sessions.create(SessionId('empty-source'))
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
|
||||
|
||||
await expectFailure(controller.fork({ sessionId: source.id }), 'fork-unavailable')
|
||||
await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -225,7 +230,7 @@ describe('Session fork failures', () => {
|
||||
origin: 'subagent',
|
||||
})
|
||||
const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default')
|
||||
await expectFailure(lineageController.fork({ sessionId: child.id }), 'internal')
|
||||
await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal')
|
||||
await lineage.fiber.dispose()
|
||||
|
||||
const creation = await baseContext()
|
||||
@@ -233,7 +238,7 @@ describe('Session fork failures', () => {
|
||||
const source = completedSession(creation, 'creation-source', '/workspace')
|
||||
vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed'))
|
||||
const creationController = new SessionCommandController(creation, controllerAgents(), '/default')
|
||||
await expectFailure(creationController.fork({ sessionId: source.id }), 'internal')
|
||||
await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal')
|
||||
await creation.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -251,7 +256,7 @@ describe('Session fork failures', () => {
|
||||
)
|
||||
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
|
||||
|
||||
await expectFailure(controller.fork({ sessionId: source.id }), 'workspace-attach-failed')
|
||||
await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed')
|
||||
const options = create.mock.calls[0]?.[0]
|
||||
if (options === undefined) throw new Error('Agent creation was not attempted')
|
||||
expect(options.meta).not.toHaveProperty('cwd')
|
||||
|
||||
@@ -56,7 +56,7 @@ async function commandHarness(): Promise<{
|
||||
}
|
||||
|
||||
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
|
||||
await expect(operation).rejects.toMatchObject({ failure: { code } })
|
||||
await expect(operation).rejects.toMatchObject({ code })
|
||||
}
|
||||
|
||||
describe('Session queue commands', () => {
|
||||
@@ -79,21 +79,21 @@ describe('Session queue commands', () => {
|
||||
},
|
||||
}],
|
||||
},
|
||||
})), 'attachment-error')
|
||||
})), 'session/attachment-invalid')
|
||||
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
|
||||
sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' },
|
||||
})), 'queue-item-not-found')
|
||||
})), 'session/queue-item-not-found')
|
||||
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
|
||||
sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' },
|
||||
})), 'queue-item-not-found')
|
||||
})), 'session/queue-item-not-found')
|
||||
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
|
||||
sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' },
|
||||
})), 'steer-unavailable')
|
||||
})), 'session/steer-unavailable')
|
||||
|
||||
Object.assign(agent, { status: 'idle' })
|
||||
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
|
||||
sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
|
||||
})), 'steer-unavailable')
|
||||
})), 'session/steer-unavailable')
|
||||
expect(controller.updateQueue({
|
||||
sessionId: agent.id,
|
||||
itemId: queued.id,
|
||||
@@ -114,7 +114,7 @@ describe('Session queue commands', () => {
|
||||
|
||||
await expectFailure(Promise.resolve().then(() => controller.cancel({
|
||||
sessionId: SessionId('missing'),
|
||||
})), 'session-not-found')
|
||||
})), 'session/not-found')
|
||||
expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true })
|
||||
expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
|
||||
await ctx.fiber.dispose()
|
||||
@@ -209,7 +209,7 @@ describe('Session attachment authorization', () => {
|
||||
)
|
||||
await expectFailure(noPersistenceController.attachment({
|
||||
sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
|
||||
}), 'session-not-found')
|
||||
}), 'session/not-found')
|
||||
|
||||
const missing = new Context()
|
||||
await missing.plugin(SessionStore)
|
||||
@@ -225,7 +225,7 @@ describe('Session attachment authorization', () => {
|
||||
)
|
||||
await expectFailure(missingController.attachment({
|
||||
sessionId: SessionId('missing'), attachmentId: 'att' as never,
|
||||
}), 'session-not-found')
|
||||
}), 'session/not-found')
|
||||
|
||||
for (const thrown of [
|
||||
new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'),
|
||||
@@ -239,7 +239,7 @@ describe('Session attachment authorization', () => {
|
||||
await expectFailure(fixture.controller.attachment({
|
||||
sessionId: fixture.sessionId,
|
||||
attachmentId: ref.attachmentId,
|
||||
}), thrown instanceof AttachmentError ? 'attachment-error' : 'internal')
|
||||
}), thrown instanceof AttachmentError ? 'session/attachment-invalid' : 'gateway/internal')
|
||||
await fixture.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
@@ -257,7 +257,7 @@ describe('Session attachment authorization', () => {
|
||||
|
||||
await expectFailure(controller.attachment({
|
||||
sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
|
||||
}), 'internal')
|
||||
}), 'gateway/internal')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SessionController from '../src/index.ts'
|
||||
import type { ApiSessionAgentController } from '../src/agent.ts'
|
||||
@@ -124,7 +125,7 @@ describe('SessionController facade', () => {
|
||||
if (outcome === 'success') resolve.mockResolvedValue({ agent: live })
|
||||
else if (outcome === 'domain-error') {
|
||||
resolve.mockResolvedValue({
|
||||
error: { code: 'internal', message: 'activation unavailable', details: {} },
|
||||
error: new RemoteError('gateway/internal', 'activation unavailable', {}),
|
||||
})
|
||||
} else {
|
||||
resolve.mockRejectedValue(new Error('activation crashed'))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl.
|
||||
import type {
|
||||
MessageId,
|
||||
RpcError, RpcResponse, SessionId, SessionSearchItem,
|
||||
SessionId, SessionSearchItem,
|
||||
SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
@@ -24,10 +24,8 @@ import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-contro
|
||||
import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
RemoteStream,
|
||||
RemoteStreamError,
|
||||
type RemoteStreamOptions,
|
||||
} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
|
||||
import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts'
|
||||
|
||||
@@ -72,28 +70,21 @@ export function deferred<T>(): Deferred<T> {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
let nextRpc = 0
|
||||
|
||||
export function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
|
||||
}
|
||||
|
||||
export function err<T>(error: RpcError): RpcResponse<T> {
|
||||
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } }
|
||||
}
|
||||
|
||||
/** Successful generated Remote result for programmable domain fakes. */
|
||||
export function remoteOk<T>(value: T): RemoteResult<T> {
|
||||
/**
|
||||
* Successful generated Remote result for programmable domain fakes.
|
||||
* @param value - the value the Host answers with.
|
||||
* @returns the success branch of a Remote result.
|
||||
*/
|
||||
export function ok<T>(value: T): RemoteResult<T> {
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed generated Remote result carrying an owner's own failure vocabulary,
|
||||
* which the carrier's closed RPC code set does not contain.
|
||||
* Failed generated Remote result carrying the owner's declared failure.
|
||||
* @param error - the owner-declared failure.
|
||||
* @returns the failure branch of a Remote result.
|
||||
*/
|
||||
export function remoteErr<T>(error: RemoteFailure): RemoteResult<T> {
|
||||
export function err<T>(error: RemoteFailure): RemoteResult<T> {
|
||||
return { ok: false, error }
|
||||
}
|
||||
|
||||
@@ -129,11 +120,11 @@ export class FakeApiClient {
|
||||
readonly followStarts: SessionId[] = []
|
||||
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
|
||||
onList: (payload: unknown) => Promise<RemoteResult<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onSearch: (payload: unknown) => Promise<RemoteResult<{ items: SessionSearchItem[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ items: [], hasMore: false }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onSelectModel: (payload: SessionSelectModelRequest) => Promise<RpcResponse<SessionSelectModelValue>> =
|
||||
onCreate: (payload: unknown) => Promise<RemoteResult<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
onSelectModel: (payload: SessionSelectModelRequest) => Promise<RemoteResult<SessionSelectModelValue>> =
|
||||
payload => Promise.resolve(ok({
|
||||
selected: {
|
||||
provider: payload.provider,
|
||||
@@ -143,19 +134,19 @@ export class FakeApiClient {
|
||||
: { reasoningEffort: payload.reasoningEffort }),
|
||||
},
|
||||
}))
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onRename: (payload: unknown) => Promise<RemoteResult<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RemoteResult<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
|
||||
=> Promise<RemoteResult<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
|
||||
() => Promise.resolve(ok({ records: [], hasMore: false }))
|
||||
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
|
||||
onPrompt: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onAttachment: (payload: unknown) => Promise<RemoteResult<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
|
||||
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onOpenWorkspacePath: (payload: unknown) => Promise<RemoteResult<{ opened: true }>> =
|
||||
() => Promise.resolve(remoteOk({ opened: true as const }))
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
|
||||
private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
|
||||
@@ -174,30 +165,30 @@ export class FakeApiClient {
|
||||
lastSearchSignal: AbortSignal | undefined
|
||||
|
||||
onSubagentList: (payload: unknown) => Promise<RemoteResult<SubagentCatalog>>
|
||||
= () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RemoteResult<SubagentPromptReceipt>>
|
||||
= () => Promise.resolve(remoteOk({ messageId: 'fake-message' as MessageId }))
|
||||
= () => Promise.resolve(ok({ messageId: 'fake-message' as MessageId }))
|
||||
|
||||
onSubagentInterrupt: (payload: unknown) => Promise<RemoteResult<SubagentInterruptReceipt>>
|
||||
= () => Promise.resolve(remoteOk({ accepted: true as const }))
|
||||
= () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
onWorkspaceCreate: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
|
||||
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true }))
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
|
||||
|
||||
onWorkspaceRename: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
onWorkspaceDelete: (payload: unknown) => Promise<RemoteResult<{ deleted: true }>> =
|
||||
() => Promise.resolve(remoteOk({ deleted: true }))
|
||||
() => Promise.resolve(ok({ deleted: true }))
|
||||
|
||||
onWorkspaceInsertBefore: (payload: unknown) => Promise<RemoteResult<{ workspaceIds: WorkspaceId[] }>> =
|
||||
() => Promise.resolve(remoteOk({ workspaceIds: [] }))
|
||||
() => Promise.resolve(ok({ workspaceIds: [] }))
|
||||
|
||||
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
|
||||
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
|
||||
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
|
||||
|
||||
onWorkspaceArchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
|
||||
payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
|
||||
payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
|
||||
|
||||
/** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */
|
||||
sessionRemotes(): RuntimeRemotes {
|
||||
@@ -209,8 +200,8 @@ export class FakeApiClient {
|
||||
execute: () => Promise.resolve({ ok: true, value: undefined }),
|
||||
},
|
||||
session: {
|
||||
canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)),
|
||||
list: payload => this.remoteResult('session.list', payload, this.onList(payload)),
|
||||
canOpenWorkspacePath: () => Promise.resolve(ok(true)),
|
||||
list: payload => this.record('session.list', payload, this.onList(payload)),
|
||||
modelCatalog: () => Promise.resolve({
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -222,20 +213,20 @@ export class FakeApiClient {
|
||||
}),
|
||||
search: (payload, signal) => {
|
||||
this.lastSearchSignal = signal
|
||||
return this.remoteResult('session.search', payload, this.onSearch(payload))
|
||||
return this.record('session.search', payload, this.onSearch(payload))
|
||||
},
|
||||
create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)),
|
||||
selectModel: payload => this.remoteResult(
|
||||
create: payload => this.record('session.create', payload, this.onCreate(payload)),
|
||||
selectModel: payload => this.record(
|
||||
'session.selectModel',
|
||||
payload,
|
||||
this.onSelectModel(payload),
|
||||
),
|
||||
rename: payload => this.remoteResult('session.rename', payload, this.onRename(payload)),
|
||||
fork: payload => this.remoteResult('session.fork', payload, this.onFork(payload)),
|
||||
prompt: payload => this.remoteResult('session.prompt', payload, this.onPrompt(payload)),
|
||||
attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)),
|
||||
updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)),
|
||||
rename: payload => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: payload => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)),
|
||||
updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
openWorkspacePath: payload => this.record(
|
||||
'session.openWorkspacePath',
|
||||
payload,
|
||||
@@ -333,21 +324,13 @@ export class FakeApiClient {
|
||||
return response
|
||||
}
|
||||
|
||||
private async remoteResult<T>(
|
||||
method: string,
|
||||
payload: unknown,
|
||||
response: Promise<RpcResponse<T>>,
|
||||
): Promise<RemoteResult<T>> {
|
||||
return (await this.record(method, payload, response)).result
|
||||
}
|
||||
|
||||
private page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
|
||||
return this.fetchPage(request)
|
||||
}
|
||||
|
||||
private async fetchPage(
|
||||
request: SessionPageRequest,
|
||||
response?: Promise<RpcResponse<SessionPage>>,
|
||||
response?: Promise<RemoteResult<SessionPage>>,
|
||||
): Promise<RemoteResult<SessionPage>> {
|
||||
const sessionId = addressSessionId(request.address)
|
||||
const payload = request.address.kind === 'session'
|
||||
@@ -366,7 +349,7 @@ export class FakeApiClient {
|
||||
...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
|
||||
}
|
||||
const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history'
|
||||
const result = await this.remoteResult(method, payload, response ?? this.onHistory({
|
||||
const result = await this.record(method, payload, response ?? this.onHistory({
|
||||
sessionId,
|
||||
throughSeq: request.throughSeq,
|
||||
...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
|
||||
@@ -398,14 +381,8 @@ export class FakeApiClient {
|
||||
sessionId,
|
||||
maxMessages: request.maxMessages ?? 50,
|
||||
})
|
||||
if (!response.result.ok) {
|
||||
throw new RemoteStreamError(
|
||||
response.result.error.code,
|
||||
response.result.error.message,
|
||||
response.result.error.details,
|
||||
)
|
||||
}
|
||||
const page = response.result.value
|
||||
if (!response.ok) throw response.error
|
||||
const page = response.value
|
||||
const tail = page.records.at(-1)
|
||||
const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail))
|
||||
yield {
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type {} from '@deepseek-ai/dsh-session-title/client'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
|
||||
import { entries, plainTurn } from './event-script.client.ts'
|
||||
|
||||
const S1 = 'fk-m1' as SessionId
|
||||
@@ -92,10 +93,10 @@ describe('list lifecycle', () => {
|
||||
|
||||
it('keeps the error in the list snapshot on failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
|
||||
api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'boom', {})))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'gateway/internal' } })
|
||||
// A failed pull does not step the arrival phase: still pending.
|
||||
expect(manager.getListSnapshot().phase).toBe('pending')
|
||||
})
|
||||
@@ -108,7 +109,7 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().phase).toBe('ready')
|
||||
// Sticky across later failures: the pull-activity axis reports the error,
|
||||
// the arrival phase holds.
|
||||
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
|
||||
api.onList = () => Promise.resolve(err(new RemoteError('gateway/internal', 'down', {})))
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
|
||||
// And across an empty re-pull (empty-with-ready = truly no sessions).
|
||||
@@ -227,25 +228,18 @@ describe('search', () => {
|
||||
expect(api.lastSearchSignal).toBe(signal)
|
||||
})
|
||||
|
||||
it('preserves business errors and folds transport failures', async () => {
|
||||
it('preserves business errors and propagates a non-Remote throw', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
api.onSearch = () => Promise.resolve(err({
|
||||
code: 'internal',
|
||||
message: 'index unavailable',
|
||||
details: {},
|
||||
}))
|
||||
api.onSearch = () => Promise.resolve(err(new RemoteError('gateway/internal', 'index unavailable', {})))
|
||||
const signal = new AbortController().signal
|
||||
await expect(manager.search('first', signal)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'index unavailable' },
|
||||
error: { code: 'gateway/internal', message: 'index unavailable' },
|
||||
})
|
||||
|
||||
api.onSearch = () => Promise.reject(new Error('wire down'))
|
||||
await expect(manager.search('second', signal)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'wire down' },
|
||||
})
|
||||
await expect(manager.search('second', signal)).rejects.toThrow('wire down')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -279,7 +273,7 @@ describe('subagent catalogs', () => {
|
||||
summary(S1),
|
||||
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
|
||||
] as never[] }))
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
@@ -375,7 +369,7 @@ describe('subagent catalogs', () => {
|
||||
it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
@@ -413,7 +407,7 @@ describe('subagent catalogs', () => {
|
||||
manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
|
||||
parentSessionId: S1, origin: 'subagent',
|
||||
}))
|
||||
response.resolve(remoteOk({
|
||||
response.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -426,7 +420,7 @@ describe('subagent catalogs', () => {
|
||||
{ kind: 'child', id: S1, hasChildren: true },
|
||||
])
|
||||
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -449,7 +443,7 @@ describe('subagent catalogs', () => {
|
||||
|
||||
manager.handleSessionStatus(S1, false)
|
||||
manager.handleSessionStatus(S2, true)
|
||||
response.resolve(remoteOk({
|
||||
response.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
|
||||
@@ -472,7 +466,7 @@ describe('subagent catalogs', () => {
|
||||
|
||||
it('marks a detached catalog child inactive without requiring a selected address', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
@@ -498,8 +492,8 @@ describe('subagent catalogs', () => {
|
||||
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
expect(manager.refreshSubagents(root)).toBe(refresh)
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
first.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
first.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
await refresh
|
||||
|
||||
expect(api.callsOf('subagents.list')).toHaveLength(1)
|
||||
@@ -524,7 +518,7 @@ describe('subagent catalogs', () => {
|
||||
manager.handleSessionAdded(summary(S2, { parentSessionId: root }))
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
api.onSubagentList = () => second.promise
|
||||
first.resolve(remoteOk({
|
||||
first.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'older',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -533,7 +527,7 @@ describe('subagent catalogs', () => {
|
||||
}))
|
||||
await refresh
|
||||
// The trailing pull is already in flight (kicked synchronously in finally).
|
||||
second.resolve(remoteOk({
|
||||
second.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'older',
|
||||
@@ -571,7 +565,7 @@ describe('subagent catalogs', () => {
|
||||
api.onSubagentList = () => first.promise
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
first.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
|
||||
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await refresh
|
||||
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
|
||||
|
||||
@@ -583,12 +577,12 @@ describe('subagent catalogs', () => {
|
||||
manager.handleSessionRemoved(root)
|
||||
const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => trailing.promise
|
||||
mid.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true }))
|
||||
mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
|
||||
await midRefresh
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
|
||||
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
|
||||
|
||||
trailing.resolve(remoteErr({ code: 'internal', message: 'trailing pull failed', details: {} }))
|
||||
trailing.resolve(err(new RemoteError('gateway/internal', 'trailing pull failed', {})))
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
|
||||
state: 'error',
|
||||
@@ -605,7 +599,7 @@ describe('subagent catalogs', () => {
|
||||
it('invalidates catalog availability when the owning parent is removed', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
api.onSubagentList = () => Promise.resolve(remoteOk({
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -625,12 +619,11 @@ describe('subagent catalogs', () => {
|
||||
})
|
||||
|
||||
describe('remaining branches', () => {
|
||||
it('refreshList folds a transport throw into the error state', async () => {
|
||||
it('refreshList propagates a non-Remote throw', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.reject(new Error('list wire down'))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
|
||||
await expect(manager.refreshList()).rejects.toThrow('list wire down')
|
||||
})
|
||||
|
||||
it('refreshList pushes running bits down to already-instantiated sessions', async () => {
|
||||
@@ -652,36 +645,32 @@ describe('remaining branches', () => {
|
||||
await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
|
||||
expect(manager.getListSnapshot().items).toHaveLength(1)
|
||||
api.onCreate = () => Promise.reject(new Error('create wire down'))
|
||||
expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
await expect(manager.create()).rejects.toThrow('create wire down')
|
||||
// Business error passes through untouched.
|
||||
api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} }))
|
||||
api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', 'no', {})))
|
||||
expect(await manager.create()).toMatchObject({ ok: false })
|
||||
})
|
||||
|
||||
it('publishes a real Ungrouped summary from workspace-attach-failed', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.resolve(err({
|
||||
code: 'workspace-attach-failed',
|
||||
message: 'published but unattached',
|
||||
details: { sessionId: S1, workspaceId: 'w1' },
|
||||
} as never))
|
||||
api.onCreate = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', {
|
||||
sessionId: S1, workspaceId: 'w1',
|
||||
})))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
|
||||
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
|
||||
})
|
||||
|
||||
it('reconciles a fork child published before workspace attachment fails', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onFork = () => Promise.resolve(err({
|
||||
code: 'workspace-attach-failed',
|
||||
message: 'forked but unattached',
|
||||
details: { sessionId: S2, workspaceId: 'w1' },
|
||||
} as never))
|
||||
api.onFork = () => Promise.resolve(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', {
|
||||
sessionId: S2, workspaceId: 'w1',
|
||||
})))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const result = await manager.fork({ sessionId: S1 })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
|
||||
expect(result).toMatchObject({ ok: false, error: { code: 'session/workspace-attach-failed' } })
|
||||
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
|
||||
sessionId: S2,
|
||||
parentSessionId: S1,
|
||||
@@ -693,8 +682,8 @@ describe('remaining branches', () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onCreate = () => Promise.reject(new Error('response lost'))
|
||||
const manager = new SessionManager(fakeRemote(api))
|
||||
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
|
||||
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
|
||||
await expect(manager.create({ workspaceId: 'w1' as never, sessionId: S1 }))
|
||||
.rejects.toThrow('response lost')
|
||||
expect(manager.getListSnapshot().items).toEqual([])
|
||||
|
||||
manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' }))
|
||||
@@ -790,8 +779,8 @@ describe('connected generation', () => {
|
||||
|
||||
manager.handleConnected()
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
|
||||
parent.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
child.resolve(remoteOk({ entries: [], parentAvailable: true }))
|
||||
parent.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
child.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('session.list')).toHaveLength(1)
|
||||
|
||||
@@ -13,7 +13,6 @@ import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
|
||||
import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
|
||||
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -493,17 +492,13 @@ describe('Remote Agent and Session lookup policy', () => {
|
||||
const sessionLookup = ctx.typert.lookups.get('session')
|
||||
if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
|
||||
const ownershipFailure = {
|
||||
failure: {
|
||||
code: 'agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
},
|
||||
code: 'session/agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
}
|
||||
|
||||
const coldFailure = Promise.resolve(agentLookup.resolve(coldId))
|
||||
const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id))
|
||||
await expect(coldFailure).rejects.toBeInstanceOf(TypertLookupFailure)
|
||||
await expect(coldFailure).rejects.toMatchObject(ownershipFailure)
|
||||
await expect(liveFailure).rejects.toBeInstanceOf(TypertLookupFailure)
|
||||
await expect(liveFailure).rejects.toMatchObject(ownershipFailure)
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
@@ -576,14 +571,14 @@ describe('subagent ownership fence', () => {
|
||||
expect(prompt.ok).toBe(false)
|
||||
if (!prompt.ok) {
|
||||
expect(prompt.error).toMatchObject({
|
||||
code: 'agent-busy',
|
||||
code: 'session/agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
})
|
||||
}
|
||||
|
||||
const create = await remote.create(request({ sessionId, cwd: '/proj' }))
|
||||
expect(create.ok).toBe(false)
|
||||
if (!create.ok) expect(create.error.code).toBe('agent-busy')
|
||||
if (!create.ok) expect(create.error.code).toBe('session/agent-busy')
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(inspect).toHaveBeenCalledTimes(3)
|
||||
@@ -626,7 +621,7 @@ describe('subagent ownership fence', () => {
|
||||
}))
|
||||
expect(resume).toHaveBeenCalledTimes(1)
|
||||
expect(prompt.ok).toBe(false)
|
||||
if (!prompt.ok) expect(prompt.error.code).toBe('internal')
|
||||
if (!prompt.ok) expect(prompt.error.code).toBe('gateway/internal')
|
||||
})
|
||||
|
||||
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
|
||||
@@ -661,7 +656,7 @@ describe('subagent ownership fence', () => {
|
||||
|
||||
const stopped = await remote.cancel(request({ sessionId: originChild.id }))
|
||||
expect(stopped.ok).toBe(false)
|
||||
if (!stopped.ok) expect(stopped.error.code).toBe('agent-busy')
|
||||
if (!stopped.ok) expect(stopped.error.code).toBe('session/agent-busy')
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
|
||||
const queued = await remote.updateQueue(request({
|
||||
@@ -670,7 +665,7 @@ describe('subagent ownership fence', () => {
|
||||
action: { kind: 'remove' },
|
||||
}))
|
||||
expect(queued.ok).toBe(false)
|
||||
if (!queued.ok) expect(queued.error.code).toBe('agent-busy')
|
||||
if (!queued.ok) expect(queued.error.code).toBe('session/agent-busy')
|
||||
expect(updateInbox).not.toHaveBeenCalled()
|
||||
|
||||
const selection = await remote.selectModel(request({
|
||||
@@ -679,11 +674,11 @@ describe('subagent ownership fence', () => {
|
||||
model: 'm',
|
||||
}))
|
||||
expect(selection.ok).toBe(false)
|
||||
if (!selection.ok) expect(selection.error.code).toBe('agent-busy')
|
||||
if (!selection.ok) expect(selection.error.code).toBe('session/agent-busy')
|
||||
|
||||
const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' }))
|
||||
expect(create.ok).toBe(false)
|
||||
if (!create.ok) expect(create.error.code).toBe('agent-busy')
|
||||
if (!create.ok) expect(create.error.code).toBe('session/agent-busy')
|
||||
|
||||
expect(ctx.agents.get(originChild.id)).toBe(originChild)
|
||||
})
|
||||
@@ -770,10 +765,10 @@ describe('subagent ownership fence', () => {
|
||||
content: [{ type: 'text' as const, text: 'invalid zone' }],
|
||||
clientTimeZone,
|
||||
}))
|
||||
expect(invalid).toEqual({
|
||||
expect(invalid).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid-time-zone',
|
||||
code: 'session/invalid-time-zone',
|
||||
message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
|
||||
details: { value: clientTimeZone },
|
||||
},
|
||||
@@ -801,7 +796,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
})
|
||||
expect(response.ok).toBe(false)
|
||||
if (!response.ok) {
|
||||
expect(response.error.code).toBe('session-not-found')
|
||||
expect(response.error.code).toBe('session/not-found')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -821,7 +816,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
|
||||
throughSeq: -1,
|
||||
})
|
||||
expect(response.ok).toBe(false)
|
||||
if (!response.ok) expect(response.error.code).toBe('session-not-found')
|
||||
if (!response.ok) expect(response.error.code).toBe('session/not-found')
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -850,7 +845,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
}))
|
||||
expect(response.ok).toBe(false)
|
||||
if (!response.ok) {
|
||||
expect(response.error.code).toBe('agent-busy')
|
||||
expect(response.error.code).toBe('session/agent-busy')
|
||||
expect(response.error.message).toBe('prompt rejected')
|
||||
expect(response.error.details).toEqual({
|
||||
reason: 'Error: agent "session-throwing" lifecycle disposed',
|
||||
@@ -891,7 +886,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
expect(selection.ok).toBe(false)
|
||||
if (!selection.ok) {
|
||||
expect(selection.error).toMatchObject({
|
||||
code: 'agent-busy',
|
||||
code: 'session/agent-busy',
|
||||
details: { reason: 'use subagent delivery for this child session' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ describe('sessions.fork', () => {
|
||||
|
||||
for (const atSeq of [-1, 0.5]) {
|
||||
await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq })))
|
||||
.resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
.resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } })
|
||||
}
|
||||
expect(ctx.sessions.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
@@ -246,7 +246,7 @@ describe('sessions.fork', () => {
|
||||
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'fork-unavailable', details: { sessionId: source.id } },
|
||||
error: { code: 'session/fork-unavailable', details: { sessionId: source.id } },
|
||||
})
|
||||
if (!response.ok) expect(response.error.message).toMatch(/has not completed/)
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
|
||||
import { ApiSessionAgentController } from '../src/agent.ts'
|
||||
import { buildModelCatalog } from '../src/catalog.ts'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { createSessionTestRemote } from './test-remote.ts'
|
||||
|
||||
function request<P>(payload: P): P {
|
||||
@@ -110,11 +110,7 @@ async function harness(logged?: {
|
||||
'Remote Rejected',
|
||||
[],
|
||||
undefined,
|
||||
new TypertRemoteFailure({
|
||||
code: 'fixture-rejected',
|
||||
message: 'fixture rejected the selection',
|
||||
details: { provider: 'remote-rejected' },
|
||||
}),
|
||||
new RemoteError('gateway/internal', 'fixture rejected the selection', {}),
|
||||
))
|
||||
ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
|
||||
ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
|
||||
@@ -225,7 +221,7 @@ describe('Web session model selection', () => {
|
||||
}))
|
||||
expect(denied).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
|
||||
error: { code: 'session/attachment-invalid', details: { reason: 'TOO_MANY_IMAGES' } },
|
||||
})
|
||||
expect(saveImage).toHaveBeenCalledTimes(2)
|
||||
await ctx.fiber.dispose()
|
||||
@@ -294,7 +290,7 @@ describe('Web session model selection', () => {
|
||||
}))
|
||||
expect(denied).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
|
||||
error: { code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
|
||||
})
|
||||
expect(readImage).toHaveBeenCalledOnce()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -423,7 +419,7 @@ describe('Web session model selection', () => {
|
||||
expect(unsupported).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'model-unavailable',
|
||||
code: 'session/model-unavailable',
|
||||
message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"',
|
||||
},
|
||||
})
|
||||
@@ -433,10 +429,10 @@ describe('Web session model selection', () => {
|
||||
provider: 'missing',
|
||||
model: 'model',
|
||||
}))
|
||||
expect(rejected).toEqual({
|
||||
expect(rejected).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'model-unavailable',
|
||||
code: 'session/model-unavailable',
|
||||
message: 'no adapter registered for provider "missing"',
|
||||
details: { provider: 'missing', model: 'model' },
|
||||
},
|
||||
@@ -445,12 +441,12 @@ describe('Web session model selection', () => {
|
||||
sessionId,
|
||||
provider: 'remote-rejected',
|
||||
model: 'model',
|
||||
}))).toEqual({
|
||||
}))).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'fixture-rejected',
|
||||
code: 'gateway/internal',
|
||||
message: 'fixture rejected the selection',
|
||||
details: { provider: 'remote-rejected' },
|
||||
details: {},
|
||||
},
|
||||
})
|
||||
expect(currentSelection(ctx, sessionId))
|
||||
@@ -561,7 +557,7 @@ describe('Web session model selection', () => {
|
||||
}))
|
||||
expect(refused).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
|
||||
error: { code: 'session/model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
|
||||
})
|
||||
const unavailableCatalog = await buildModelCatalog(ctx)
|
||||
expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false)
|
||||
@@ -621,9 +617,7 @@ describe('Web session model selection', () => {
|
||||
saveImages: () => {
|
||||
if (saveMode === 'error') return Promise.reject(new Error('image store offline'))
|
||||
if (saveMode === 'remote') {
|
||||
return Promise.reject(new TypertRemoteFailure({
|
||||
code: 'fixture-rejected', message: 'fixture rejected', details: {},
|
||||
}))
|
||||
return Promise.reject(new RemoteError('gateway/internal', 'fixture rejected', {}))
|
||||
}
|
||||
return Promise.resolve([savedRef])
|
||||
},
|
||||
@@ -643,7 +637,7 @@ describe('Web session model selection', () => {
|
||||
sessionId, mode: 'queue', content: [image],
|
||||
}))).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
|
||||
error: { code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
|
||||
})
|
||||
|
||||
expectValue(await remote.selectModel(request({
|
||||
@@ -653,17 +647,17 @@ describe('Web session model selection', () => {
|
||||
sessionId, mode: 'queue', content: [{ ...image, data: '' }],
|
||||
}))).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'attachment-error', details: { reason: 'INVALID_IMAGE_BASE64' } },
|
||||
error: { code: 'session/attachment-invalid', details: { reason: 'INVALID_IMAGE_BASE64' } },
|
||||
})
|
||||
|
||||
saveMode = 'error'
|
||||
expect(await remote.prompt(promptRequest({
|
||||
sessionId, mode: 'queue', content: [image],
|
||||
}))).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
|
||||
}))).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } })
|
||||
saveMode = 'remote'
|
||||
expect(await remote.prompt(promptRequest({
|
||||
sessionId, mode: 'queue', content: [image],
|
||||
}))).toMatchObject({ ok: false, error: { code: 'fixture-rejected' } })
|
||||
}))).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'fixture rejected' } })
|
||||
saveMode = 'success'
|
||||
expectValue(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image] })))
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
@@ -681,13 +675,13 @@ describe('Web session model selection', () => {
|
||||
expect(await remote.selectModel(request({
|
||||
sessionId, provider: 'metadata-broken', model: 'broken',
|
||||
}))).toMatchObject({
|
||||
ok: false, error: { code: 'model-unavailable', message: 'reasoning metadata offline' },
|
||||
ok: false, error: { code: 'session/model-unavailable', message: 'reasoning metadata offline' },
|
||||
})
|
||||
expect(await remote.selectModel(request({
|
||||
sessionId, provider: 'string-error', model: 'broken',
|
||||
}))).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'model-unavailable', message: 'string selection failure' },
|
||||
error: { code: 'session/model-unavailable', message: 'string selection failure' },
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('session/openWorkspacePath', () => {
|
||||
})
|
||||
|
||||
await expect(remote.openWorkspacePath({ path: '' }))
|
||||
.resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
.resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } })
|
||||
expect(openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -105,13 +105,13 @@ describe('session/openWorkspacePath', () => {
|
||||
await expect(remote.openWorkspacePath({ path: 'result.html' }))
|
||||
.resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'path open failed: desktop unavailable' },
|
||||
error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' },
|
||||
})
|
||||
|
||||
const aborted = new AbortController()
|
||||
aborted.abort(new Error('cancelled'))
|
||||
aborted.abort(new Error('gateway/cancelled'))
|
||||
await expect(remote.openWorkspacePath({ path: 'result.html' }, aborted.signal))
|
||||
.resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } })
|
||||
.resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
|
||||
})
|
||||
|
||||
it('classifies opener cancellation and non-Error failures', async () => {
|
||||
@@ -119,7 +119,7 @@ describe('session/openWorkspacePath', () => {
|
||||
const aborted = new AbortController()
|
||||
const openPath = vi.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
aborted.abort(new Error('cancelled'))
|
||||
aborted.abort(new Error('gateway/cancelled'))
|
||||
throw new Error('opening stopped')
|
||||
})
|
||||
.mockRejectedValueOnce('desktop unavailable')
|
||||
@@ -130,11 +130,11 @@ describe('session/openWorkspacePath', () => {
|
||||
})
|
||||
|
||||
await expect(controller.openWorkspacePath({ path: 'first.html' }, aborted.signal))
|
||||
.rejects.toMatchObject({ failure: { code: 'cancelled' } })
|
||||
.rejects.toMatchObject({ code: 'gateway/cancelled' })
|
||||
await expect(controller.openWorkspacePath({
|
||||
path: 'second.html',
|
||||
}, new AbortController().signal)).rejects.toMatchObject({
|
||||
failure: { code: 'internal', message: 'path open failed: desktop unavailable' },
|
||||
code: 'gateway/internal', message: 'path open failed: desktop unavailable',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
|
||||
import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts'
|
||||
@@ -98,7 +99,7 @@ describe('beginSubmission', () => {
|
||||
describe('prompt-coupled retirement', () => {
|
||||
it('a rejected identified prompt retires its echo immediately alongside promptError', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
|
||||
api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
text: '失败的',
|
||||
@@ -121,7 +122,7 @@ describe('prompt-coupled retirement', () => {
|
||||
|
||||
it('an unidentified prompt failure leaves registered echoes alone', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } }))
|
||||
api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
|
||||
session.beginSubmission({ text: '还在', images: [] })
|
||||
await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
|
||||
@@ -6,9 +6,10 @@ import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
||||
import { agentPresetProjectionDefinition, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
|
||||
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSessionTestRemote } from './test-remote.ts'
|
||||
|
||||
@@ -26,7 +27,13 @@ function roster(ids: readonly string[]): unknown {
|
||||
defaultId: ids[0],
|
||||
resolve: (id?: string) => {
|
||||
const wanted = id ?? ids[0] ?? ''
|
||||
if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
|
||||
if (!ids.includes(wanted)) {
|
||||
return Promise.reject(new RemoteError(
|
||||
'agent-preset/not-found',
|
||||
`agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`,
|
||||
{ agentPreset: wanted, available: ids },
|
||||
))
|
||||
}
|
||||
return Promise.resolve(presetOf(wanted))
|
||||
},
|
||||
mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
|
||||
@@ -91,7 +98,7 @@ describe('session.create Agent preset identity', () => {
|
||||
|
||||
const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' })
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset-not-found' } })
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } })
|
||||
})
|
||||
|
||||
it('refuses to adopt a live Session under a different preset', async () => {
|
||||
@@ -103,7 +110,7 @@ describe('session.create Agent preset identity', () => {
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent-preset-conflict',
|
||||
code: 'agent-preset/conflict',
|
||||
details: {
|
||||
sessionId: 's4',
|
||||
requestedPreset: 'standard',
|
||||
@@ -153,7 +160,7 @@ describe('session.create Agent preset identity', () => {
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'agent-preset-conflict',
|
||||
code: 'agent-preset/conflict',
|
||||
details: {
|
||||
sessionId: 's7',
|
||||
requestedPreset: 'standard',
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('sessions.rename', () => {
|
||||
expect(response.ok).toBe(false)
|
||||
if (!response.ok) {
|
||||
expect(response.error).toMatchObject({
|
||||
code: 'title-invalid',
|
||||
code: 'session/title-invalid',
|
||||
details: { sessionId: source.id },
|
||||
})
|
||||
// The message renders verbatim in the rename dialog's alert.
|
||||
@@ -109,7 +109,7 @@ describe('sessions.rename', () => {
|
||||
|
||||
const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' }))
|
||||
expect(response.ok).toBe(false)
|
||||
if (!response.ok) expect(response.error.code).toBe('internal')
|
||||
if (!response.ok) expect(response.error.code).toBe('gateway/internal')
|
||||
})
|
||||
|
||||
it('answers internal when the composition mounts no session-title service', async () => {
|
||||
@@ -119,7 +119,7 @@ describe('sessions.rename', () => {
|
||||
const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' }))
|
||||
expect(response.ok).toBe(false)
|
||||
if (!response.ok) {
|
||||
expect(response.error.code).toBe('internal')
|
||||
expect(response.error.code).toBe('gateway/internal')
|
||||
expect(response.error.message).toMatch(/mounts no session-title service/)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('session.search', () => {
|
||||
const list = new ApiSessionList(ctx, 0)
|
||||
|
||||
await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({
|
||||
failure: { code: 'internal' },
|
||||
code: 'gateway/internal',
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -191,7 +191,7 @@ describe('session.search', () => {
|
||||
|
||||
for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) {
|
||||
await expect(remote.search(request(query), new AbortController().signal))
|
||||
.resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
|
||||
.resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } })
|
||||
}
|
||||
expect(searchSessions).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -353,7 +353,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
if (response.ok) throw new Error('unreachable')
|
||||
expect(response.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.error).toMatchObject({ code: 'gateway/internal' })
|
||||
expect(response.error.message).toContain('100-call work budget')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(100)
|
||||
})
|
||||
@@ -457,7 +457,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
if (response.ok) throw new Error('unreachable')
|
||||
expect(response.error.code).toBe('internal')
|
||||
expect(response.error.code).toBe('gateway/internal')
|
||||
expect(response.error.message).toContain('100-call work budget')
|
||||
expect(response).not.toHaveProperty('value')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(100)
|
||||
@@ -486,7 +486,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
error: { code: 'gateway/cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -507,7 +507,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
error: { code: 'gateway/internal' },
|
||||
})
|
||||
expect(response).not.toHaveProperty('value')
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
@@ -531,7 +531,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
error: { code: 'gateway/internal' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
expect(searchSessions.mock.calls.map(([providerRequest]) => (
|
||||
@@ -558,7 +558,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
error: { code: 'gateway/internal' },
|
||||
})
|
||||
expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit))
|
||||
.toEqual([20, 10, 5, 2, 1])
|
||||
@@ -584,7 +584,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
error: { code: 'gateway/cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -603,7 +603,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
if (response.ok) throw new Error('unreachable')
|
||||
expect(response.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.error).toMatchObject({ code: 'gateway/internal' })
|
||||
expect(response.error.message).toContain('returned 21 items; maximum is 20')
|
||||
})
|
||||
|
||||
@@ -629,7 +629,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
if (response.ok) throw new Error('unreachable')
|
||||
expect(response.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.error).toMatchObject({ code: 'gateway/internal' })
|
||||
expect(response.error.message).toContain('returned 11 items; maximum is 10')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -677,7 +677,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
if (response.ok) throw new Error('unreachable')
|
||||
expect(response.error).toMatchObject({ code: 'internal' })
|
||||
expect(response.error).toMatchObject({ code: 'gateway/internal' })
|
||||
expect(response.error.message).toContain('repeated a continuation cursor')
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
@@ -700,7 +700,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal' },
|
||||
error: { code: 'gateway/internal' },
|
||||
})
|
||||
expect(response).not.toHaveProperty('value')
|
||||
if (response.ok) throw new Error('unreachable')
|
||||
@@ -755,7 +755,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
error: { code: 'gateway/cancelled' },
|
||||
})
|
||||
expect(searchSessions).toHaveBeenCalledTimes(2)
|
||||
for (const call of searchSessions.mock.calls) {
|
||||
@@ -821,7 +821,7 @@ describe('session.search', () => {
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
error: { code: 'gateway/cancelled' },
|
||||
})
|
||||
expect(list).toHaveBeenCalledOnce()
|
||||
expect(locateCalls).toBe(0)
|
||||
@@ -863,7 +863,7 @@ describe('session.search', () => {
|
||||
)
|
||||
expect(cancelledBeforeLookup).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
error: { code: 'gateway/cancelled' },
|
||||
})
|
||||
|
||||
const ctx = await baseContext()
|
||||
@@ -881,7 +881,7 @@ describe('session.search', () => {
|
||||
)
|
||||
expect(cancelled).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'cancelled' },
|
||||
error: { code: 'gateway/cancelled' },
|
||||
})
|
||||
|
||||
const failed = await remote.search(
|
||||
@@ -890,7 +890,7 @@ describe('session.search', () => {
|
||||
)
|
||||
expect(failed.ok).toBe(false)
|
||||
if (failed.ok) throw new Error('unreachable')
|
||||
expect(failed.error.code).toBe('internal')
|
||||
expect(failed.error.code).toBe('gateway/internal')
|
||||
expect(failed.error.message).toContain('database unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -161,9 +161,9 @@ describe('SessionSkillCatalog', () => {
|
||||
'session "missing-skills" not found',
|
||||
'SESSION_QUERY_SESSION_NOT_FOUND',
|
||||
),
|
||||
code: 'session-not-found',
|
||||
code: 'session/not-found',
|
||||
},
|
||||
{ error: new Error('storage offline'), code: 'internal' },
|
||||
{ error: new Error('storage offline'), code: 'gateway/internal' },
|
||||
] as const)('classifies failed Session inspection as $code', async ({ error, code }) => {
|
||||
const ctx = await context()
|
||||
ctx.provide('sessionQuery', { observeSession: () => Promise.reject(error) } as never)
|
||||
@@ -172,7 +172,7 @@ describe('SessionSkillCatalog', () => {
|
||||
await expect(catalog.list(
|
||||
{ sessionId: SessionId('missing-skills') },
|
||||
new AbortController().signal,
|
||||
)).rejects.toMatchObject({ failure: { code } })
|
||||
)).rejects.toMatchObject({ code })
|
||||
})
|
||||
|
||||
it('reports an absent skill registry instead of an empty catalog', async () => {
|
||||
@@ -184,7 +184,7 @@ describe('SessionSkillCatalog', () => {
|
||||
const catalog = new SessionSkillCatalog(ctx)
|
||||
|
||||
const failed = catalog.list({ sessionId }, new AbortController().signal)
|
||||
await expect(failed).rejects.toMatchObject({ failure: { code: 'internal' } })
|
||||
await expect(failed).rejects.toMatchObject({ code: 'gateway/internal' })
|
||||
await expect(failed).rejects.toThrow('skill registry is absent')
|
||||
})
|
||||
|
||||
@@ -199,10 +199,10 @@ describe('SessionSkillCatalog', () => {
|
||||
const catalog = new SessionSkillCatalog(ctx)
|
||||
|
||||
const unprojected = catalog.list({ sessionId }, new AbortController().signal)
|
||||
await expect(unprojected).rejects.toMatchObject({ failure: { code: 'internal' } })
|
||||
await expect(unprojected).rejects.toMatchObject({ code: 'gateway/internal' })
|
||||
await expect(unprojected).rejects.toThrow('projected Session observation')
|
||||
const cwdless = catalog.list({ sessionId }, new AbortController().signal)
|
||||
await expect(cwdless).rejects.toMatchObject({ failure: { code: 'internal' } })
|
||||
await expect(cwdless).rejects.toMatchObject({ code: 'gateway/internal' })
|
||||
await expect(cwdless).rejects.toThrow('has no project cwd')
|
||||
})
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('SessionSkillCatalog', () => {
|
||||
|
||||
await expect(catalog.list({ sessionId }, new AbortController().signal))
|
||||
.rejects.toMatchObject({
|
||||
failure: { code: 'internal', message: 'skill listing failed: Error: catalog offline' },
|
||||
code: 'gateway/internal', message: 'skill listing failed: Error: catalog offline',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Session object lifecycle, event-window transport, commands, and resync behavior. */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr } from './fake-api.client.ts'
|
||||
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
|
||||
import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
@@ -76,21 +76,19 @@ describe('Session open', () => {
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
})
|
||||
|
||||
it('lands an error result in openState=error with the RpcError kept', async () => {
|
||||
it('lands an error result in openState=error with the Remote failure kept', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } }))
|
||||
api.onHistory = () => Promise.resolve(err(new RemoteError('session/not-found', 'gone', { sessionId: SID })))
|
||||
await session.open()
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.openState).toBe('error')
|
||||
expect(snapshot.openError?.code).toBe('session-not-found')
|
||||
expect(snapshot.openError?.code).toBe('session/not-found')
|
||||
})
|
||||
|
||||
it('folds a transport throw into openState=error / internal', async () => {
|
||||
it('propagates a non-Remote throw raised while opening', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.reject(new Error('socket died'))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().openState).toBe('error')
|
||||
expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' })
|
||||
await expect(session.open()).rejects.toThrow('socket died')
|
||||
})
|
||||
|
||||
it('stitches live frames arriving while history is pending, dropping the page overlap', async () => {
|
||||
@@ -283,23 +281,24 @@ describe('prompt and cancel errors', () => {
|
||||
|
||||
it('lands an interrupt business failure in promptError with op=stop', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentInterrupt = () => Promise.resolve(remoteErr({
|
||||
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
|
||||
}))
|
||||
api.onSubagentInterrupt = () => Promise.resolve(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID })))
|
||||
const session = new Session(SID, fakeRemote(api), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await session.open()
|
||||
const cancelled = await session.cancel()
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent/unauthorized' } })
|
||||
expect(session.getSnapshot().promptError).toMatchObject({
|
||||
op: 'stop', error: { code: 'subagent-unauthorized' },
|
||||
op: 'stop', error: { code: 'subagent/unauthorized' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
|
||||
it('sends a one-shot address to the Host under the continuable marker', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError(
|
||||
'subagent/not-resumable', 'subagent cannot be resumed', { childSessionId: SID },
|
||||
)))
|
||||
const session = new Session(SID, fakeRemote(api), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
|
||||
})
|
||||
@@ -307,8 +306,15 @@ describe('prompt and cancel errors', () => {
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
const cancelled = await session.cancel()
|
||||
|
||||
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
|
||||
// The Host reads the durable descriptor; the wire marker stays 'continuable'.
|
||||
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent/not-resumable' } })
|
||||
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('subagents.prompt')).toMatchObject([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
])
|
||||
expect(api.callsOf('subagents.interruptByParent')).toEqual([
|
||||
{ childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
|
||||
])
|
||||
expect(api.callsOf('session.follow')).toEqual([
|
||||
{
|
||||
address: {
|
||||
@@ -318,11 +324,35 @@ describe('prompt and cancel errors', () => {
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('subagent.history')).toEqual([])
|
||||
expect(api.callsOf('subagents.prompt')).toEqual([])
|
||||
expect(api.callsOf('subagents.interruptByParent')).toEqual([])
|
||||
expect(api.callsOf('session.cancel')).toEqual([])
|
||||
})
|
||||
|
||||
it('delivers an image continuation to the Host, which refuses it', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError(
|
||||
'subagent/attachment-unsupported',
|
||||
'subagent continuation does not accept images',
|
||||
{ childSessionId: SID, reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
|
||||
)))
|
||||
const session = new Session(SID, fakeRemote(api), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt(
|
||||
[{ type: 'text', text: '看图' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }],
|
||||
'queue',
|
||||
)
|
||||
|
||||
expect(prompted).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'subagent/attachment-unsupported', details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' } },
|
||||
})
|
||||
// The image reaches the wire unfiltered: refusing it is the Host's call.
|
||||
expect(api.callsOf('subagents.prompt')).toMatchObject([
|
||||
{ content: [{ type: 'text' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => {
|
||||
const { api, session } = makeSession()
|
||||
session.handleBlank(true)
|
||||
@@ -351,21 +381,20 @@ describe('prompt and cancel errors', () => {
|
||||
it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => {
|
||||
const { api, session } = makeSession()
|
||||
session.handleBlank(true)
|
||||
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
|
||||
api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' })))
|
||||
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
|
||||
expect(result.ok).toBe(false)
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } })
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
blank: true, promptAttempted: true, awaitingFirstTurn: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('lands cancel failures in promptError with op=stop', async () => {
|
||||
it('propagates a non-Remote throw raised while cancelling', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onCancel = () => Promise.reject(new Error('cancel transport down'))
|
||||
const result = await session.cancel()
|
||||
expect(result.ok).toBe(false)
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
|
||||
await expect(session.cancel()).rejects.toThrow('cancel transport down')
|
||||
expect(session.getSnapshot().promptError).toBeNull()
|
||||
})
|
||||
|
||||
it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
|
||||
@@ -400,32 +429,28 @@ describe('rename', () => {
|
||||
|
||||
it('returns the business error untouched and folds a transport throw to internal', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onRename = () => Promise.resolve(err({
|
||||
code: 'title-invalid', message: 'empty', details: { sessionId: SID },
|
||||
} as never))
|
||||
api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'empty', { sessionId: SID })))
|
||||
const rejected = await session.rename(' ')
|
||||
expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
|
||||
expect(rejected).toMatchObject({ ok: false, error: { code: 'session/title-invalid' } })
|
||||
expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
|
||||
api.onRename = () => Promise.reject(new Error('rename transport down'))
|
||||
const folded = await session.rename('x')
|
||||
expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
|
||||
await expect(session.rename('x')).rejects.toThrow('rename transport down')
|
||||
})
|
||||
})
|
||||
|
||||
describe('remaining branches', () => {
|
||||
it('prompt transport throw folds to internal promptError', async () => {
|
||||
it('propagates a non-Remote throw raised while prompting', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onPrompt = () => Promise.reject(new Error('prompt wire down'))
|
||||
const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue')
|
||||
expect(result.ok).toBe(false)
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } })
|
||||
await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('prompt wire down')
|
||||
expect(session.getSnapshot().promptError).toBeNull()
|
||||
})
|
||||
|
||||
it('cancel business error also lands op=stop promptError', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } }))
|
||||
api.onCancel = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'nope', { reason: 'r' })))
|
||||
await session.cancel()
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } })
|
||||
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'session/agent-busy' } })
|
||||
})
|
||||
|
||||
it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => {
|
||||
@@ -435,7 +460,7 @@ describe('remaining branches', () => {
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
|
||||
await session.open()
|
||||
// err result: window unchanged
|
||||
api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
|
||||
api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {})))
|
||||
await session.loadOlder()
|
||||
expect(eventSeqs(session)).toHaveLength(6)
|
||||
expect(session.getSnapshot().hasMore).toBe(true)
|
||||
@@ -490,7 +515,7 @@ describe('remaining branches', () => {
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.openState).toBe('error')
|
||||
expect(snapshot.openError).toMatchObject({
|
||||
code: 'internal', message: 'session event stream page did not end at its requested cursor',
|
||||
code: 'gateway/internal', message: 'session event stream page did not end at its requested cursor',
|
||||
})
|
||||
expect(eventSeqs(session)).toEqual([])
|
||||
})
|
||||
@@ -508,7 +533,7 @@ describe('remaining branches', () => {
|
||||
const { api, session } = makeSession()
|
||||
await follow(api, ev.user(0, '冷态帧'))
|
||||
expect(eventSeqs(session)).toEqual([])
|
||||
api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
|
||||
api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {})))
|
||||
await session.open()
|
||||
await follow(api, ev.user(0, '错态帧'))
|
||||
expect(eventSeqs(session)).toEqual([])
|
||||
@@ -518,16 +543,14 @@ describe('remaining branches', () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const failure = {
|
||||
code: 'session-not-found',
|
||||
message: 'session disappeared',
|
||||
details: { sessionId: SID },
|
||||
}
|
||||
const failure = new RemoteError('session/not-found', 'session disappeared', { sessionId: SID })
|
||||
|
||||
api.failStreams(new RemoteStreamError(failure.code, failure.message, failure.details))
|
||||
api.failStreams(failure)
|
||||
await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
|
||||
|
||||
expect(session.getSnapshot().openError).toEqual(failure)
|
||||
expect(session.getSnapshot().openError).toMatchObject({
|
||||
code: failure.code, message: failure.message, details: failure.details,
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => {
|
||||
@@ -545,10 +568,10 @@ describe('remaining branches', () => {
|
||||
follow(api, ev.user(10, '洞二')),
|
||||
])
|
||||
await vi.waitFor(() => { expect(repairs).toBe(1) })
|
||||
gate.reject(new Error('repair wire down'))
|
||||
gate.reject(new RemoteError('gateway/internal', 'repair wire down', {}))
|
||||
await deliveries
|
||||
await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
|
||||
expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'repair wire down' })
|
||||
expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'repair wire down' })
|
||||
expect(eventSeqs(session)).toHaveLength(6)
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts'
|
||||
import { scopeOf } from '../src/client/scope.ts'
|
||||
import type { SessionFollowFrame } from '../src/types.ts'
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
err,
|
||||
fakeRemote,
|
||||
ok,
|
||||
remoteOk,
|
||||
type RuntimeRemotes,
|
||||
} from './fake-api.client.ts'
|
||||
|
||||
@@ -525,7 +525,7 @@ describe('catalog-addressed navigation', () => {
|
||||
b.api.onSubagentList = (payload) => {
|
||||
const parentSessionId = payload as SessionId
|
||||
if (parentSessionId === sid('root')) {
|
||||
return Promise.resolve(remoteOk({
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
@@ -534,7 +534,7 @@ describe('catalog-addressed navigation', () => {
|
||||
}))
|
||||
}
|
||||
if (parentSessionId === sid('child')) {
|
||||
return Promise.resolve(remoteOk({
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -542,7 +542,7 @@ describe('catalog-addressed navigation', () => {
|
||||
parentAvailable: false,
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(remoteOk({ entries: [], parentAvailable: false }))
|
||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||
}
|
||||
await feedList(b, [
|
||||
{ id: 'root' },
|
||||
@@ -564,7 +564,7 @@ describe('catalog-addressed navigation', () => {
|
||||
b.api.onSubagentList = (payload) => {
|
||||
const parentSessionId = payload as SessionId
|
||||
if (parentSessionId === sid('root')) {
|
||||
return Promise.resolve(remoteOk({
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
@@ -573,7 +573,7 @@ describe('catalog-addressed navigation', () => {
|
||||
}))
|
||||
}
|
||||
if (parentSessionId === sid('child')) {
|
||||
return Promise.resolve(remoteOk({
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
@@ -581,7 +581,7 @@ describe('catalog-addressed navigation', () => {
|
||||
parentAvailable: false,
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(remoteOk({ entries: [], parentAvailable: false }))
|
||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||
}
|
||||
await feedList(b, [{ id: 'root' }])
|
||||
await b.svc.refreshSubagents(sid('root'))
|
||||
@@ -611,15 +611,12 @@ describe('create', () => {
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
|
||||
await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
|
||||
b.api.onCreate = () => Promise.resolve({
|
||||
rpcId: 'e' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
|
||||
} as never)
|
||||
b.api.onCreate = () => Promise.resolve(err(new RemoteError('gateway/internal', '爆了', {})))
|
||||
const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(SessionCreateError)
|
||||
expect(failure).toMatchObject({
|
||||
requestedSessionId: 'candidate',
|
||||
rpcError: { code: 'internal', message: '爆了' },
|
||||
rpcError: { code: 'gateway/internal', message: '爆了' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -637,16 +634,11 @@ describe('create', () => {
|
||||
|
||||
it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => {
|
||||
const b = bench()
|
||||
b.api.onCreate = () => Promise.resolve({
|
||||
rpcId: 'attach' as never,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'workspace-attach-failed', message: 'ledger unavailable',
|
||||
details: { sessionId: sid('published'), workspaceId: 'ws' },
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
b.api.onCreate = () => Promise.resolve(err(new RemoteError(
|
||||
'session/workspace-attach-failed',
|
||||
'ledger unavailable',
|
||||
{ sessionId: sid('published'), workspaceId: 'ws' },
|
||||
)))
|
||||
const failure = await b.svc.create({
|
||||
workspaceId: 'ws' as never,
|
||||
sessionId: sid('published'),
|
||||
@@ -655,7 +647,7 @@ describe('create', () => {
|
||||
expect(failure).toBeInstanceOf(SessionCreateError)
|
||||
expect(failure).toMatchObject({
|
||||
requestedSessionId: 'published',
|
||||
rpcError: { code: 'workspace-attach-failed' },
|
||||
rpcError: { code: 'session/workspace-attach-failed' },
|
||||
})
|
||||
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true })
|
||||
})
|
||||
@@ -723,12 +715,10 @@ describe('fork', () => {
|
||||
})
|
||||
await feedList(b, [{ id: 'source' }])
|
||||
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
|
||||
b.api.onRename = () => Promise.resolve(err({
|
||||
code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
|
||||
} as never))
|
||||
b.api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') })))
|
||||
|
||||
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
|
||||
.rejects.toThrow('fork child rename failed: title-invalid: rejected')
|
||||
.rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
|
||||
expect(b.svc.binding(sid('child'))).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -785,10 +775,7 @@ describe('blank mirror', () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
|
||||
const session = b.svc.binding(sid('s1'))!.session
|
||||
b.api.onPrompt = () => Promise.resolve({
|
||||
rpcId: 'busy' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } },
|
||||
} as never)
|
||||
b.api.onPrompt = () => Promise.resolve(err(new RemoteError('gateway/internal', 'agent busy', {})))
|
||||
const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
|
||||
expect(result.ok).toBe(false)
|
||||
// No flip on failure: local stays aligned with the host authority
|
||||
|
||||
@@ -14,7 +14,8 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
|
||||
import { vi } from 'vitest'
|
||||
import {
|
||||
TypertRemoteFailure,
|
||||
RemoteError,
|
||||
remoteErrorOf,
|
||||
type RemoteResult,
|
||||
} from '@deepseek-ai/dsh-typert-protocol'
|
||||
import SessionController from '../src/index.ts'
|
||||
@@ -224,14 +225,13 @@ function remoteResult<T>(
|
||||
.catch((error: unknown) => ({
|
||||
ok: false as const,
|
||||
error: signal?.aborted === true
|
||||
? { code: 'cancelled', message: 'request was aborted', details: {} }
|
||||
: error instanceof TypertRemoteFailure
|
||||
? error.failure
|
||||
: {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
? new RemoteError('gateway/cancelled', 'request was aborted', {})
|
||||
: remoteErrorOf(error)
|
||||
?? new RemoteError(
|
||||
'gateway/internal',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{},
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
RemoteStream,
|
||||
RemoteStreamCarrierError,
|
||||
RemoteStreamError,
|
||||
type RemoteStreamOptions,
|
||||
} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
createSessionControlStream,
|
||||
SessionEventStream,
|
||||
sessionStreamFailure,
|
||||
type SessionJournalChange,
|
||||
type SessionRemote,
|
||||
} from '../src/client/index.ts'
|
||||
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
|
||||
import type {
|
||||
SessionAddress,
|
||||
SessionControlFrame,
|
||||
@@ -73,12 +73,18 @@ function snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
function sessionClient(remote: SessionTransportRemote) {
|
||||
function sessionClient(remote: SessionTransportRemote): SessionRemotes {
|
||||
return {
|
||||
session: remote as SessionRemote,
|
||||
$stream: <Item>(options: RemoteStreamOptions<Item>) => (
|
||||
new RemoteStream(AVAILABLE_CONNECTION, options)
|
||||
),
|
||||
commands: { execute: () => Promise.reject(new Error('stream tests never run commands')) },
|
||||
subagents: {
|
||||
list: () => Promise.reject(new Error('stream tests never read the subagent catalog')),
|
||||
prompt: () => Promise.reject(new Error('stream tests never prompt a subagent')),
|
||||
interruptByParent: () => Promise.reject(new Error('stream tests never interrupt a subagent')),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +301,7 @@ describe('Session Client stream adapters', () => {
|
||||
})
|
||||
|
||||
it('turns a pagination failure into a typed stream failure', async () => {
|
||||
const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const
|
||||
const failure = new RemoteError('session/not-found', 'missing', { sessionId: 'session-1' as never })
|
||||
const remote = new ScriptedSessionRemote(
|
||||
[{ frames: [snapshot(-1, [])], hold: true }],
|
||||
[{ ok: false, error: failure }],
|
||||
@@ -306,11 +312,8 @@ describe('Session Client stream adapters', () => {
|
||||
})
|
||||
|
||||
await stream.open({})
|
||||
await expect(stream.prepend({})).rejects.toBeInstanceOf(RemoteStreamError)
|
||||
await expect(stream.prepend({})).rejects.toMatchObject({ code: 'session/not-found' })
|
||||
await expect(stream.open({})).rejects.toThrow('already opened')
|
||||
expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details)))
|
||||
.toEqual(failure)
|
||||
expect(sessionStreamFailure(new Error('local'))).toBeUndefined()
|
||||
expect(remote.signals[0]?.aborted).toBe(false)
|
||||
expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }])
|
||||
await stream.dispose()
|
||||
|
||||
@@ -296,7 +296,7 @@ describe('SessionHistoryController', () => {
|
||||
id: session.id,
|
||||
events: [event('fixture/start', 0), skipped, gap],
|
||||
} as unknown as Session, gap)
|
||||
await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
|
||||
await expect(followed.next()).rejects.toMatchObject({ code: 'gateway/internal' })
|
||||
})
|
||||
|
||||
it('opens an empty source at cursor -1', async () => {
|
||||
@@ -396,15 +396,15 @@ describe('SessionHistoryController', () => {
|
||||
mode: 'continuable',
|
||||
},
|
||||
throughSeq: 0,
|
||||
}, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
|
||||
}, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' })
|
||||
await expect(transport.page({
|
||||
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' },
|
||||
throughSeq: 0,
|
||||
}, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
|
||||
}, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' })
|
||||
await expect(transport.page({
|
||||
address: { kind: 'session', sessionId: childSessionId },
|
||||
throughSeq: 0,
|
||||
}, signal)).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
|
||||
}, signal)).rejects.toMatchObject({ code: 'session/agent-busy' })
|
||||
})
|
||||
|
||||
it('preserves a cold inspection failure for the Gateway error branch', async () => {
|
||||
@@ -438,10 +438,10 @@ describe('SessionHistoryController', () => {
|
||||
{ address, throughSeq: -1, maxMessages: 0 },
|
||||
{ address, throughSeq: -1, maxMessages: 1.5 },
|
||||
]) {
|
||||
await expect(transport.page(request, signal())).rejects.toMatchObject({ failure: { code: 'bad-request' } })
|
||||
await expect(transport.page(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
}
|
||||
await expect(transport.page({ address, throughSeq: 0 }, signal()))
|
||||
.rejects.toMatchObject({ failure: { code: 'bad-request' } })
|
||||
.rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
|
||||
const corrupt = await setup()
|
||||
const corruptId = SessionId('missing-through-seq')
|
||||
@@ -455,7 +455,7 @@ describe('SessionHistoryController', () => {
|
||||
}, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
|
||||
for (const maxMessages of [0, 0.5]) {
|
||||
const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
|
||||
await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -463,7 +463,7 @@ describe('SessionHistoryController', () => {
|
||||
const { ctx, transport } = await setup()
|
||||
const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') }
|
||||
await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
|
||||
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
|
||||
.rejects.toMatchObject({ code: 'session/not-found' })
|
||||
|
||||
const inspect = vi.fn(() => Promise.resolve(undefined))
|
||||
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
||||
@@ -471,7 +471,7 @@ describe('SessionHistoryController', () => {
|
||||
inspect,
|
||||
}) as never)
|
||||
await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
|
||||
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
|
||||
.rejects.toMatchObject({ code: 'session/not-found' })
|
||||
await expect(transport.page({
|
||||
address: {
|
||||
kind: 'subagent',
|
||||
@@ -480,7 +480,7 @@ describe('SessionHistoryController', () => {
|
||||
mode: 'continuable',
|
||||
},
|
||||
throughSeq: -1,
|
||||
}, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } })
|
||||
}, signal())).rejects.toMatchObject({ code: 'subagent/not-found' })
|
||||
expect(inspect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -494,7 +494,7 @@ describe('SessionHistoryController', () => {
|
||||
inspect: () => Promise.resolve({ meta: firstHeader, events: [] }),
|
||||
}) as never)
|
||||
await expect(first.transport.page({ address, throughSeq: -1 }, signal()))
|
||||
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
|
||||
.rejects.toMatchObject({ code: 'session/not-found' })
|
||||
|
||||
const second = await setup()
|
||||
const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
|
||||
@@ -504,7 +504,7 @@ describe('SessionHistoryController', () => {
|
||||
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
|
||||
}) as never)
|
||||
await expect(second.transport.page({ address, throughSeq: -1 }, signal()))
|
||||
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
|
||||
.rejects.toMatchObject({ code: 'session/not-found' })
|
||||
})
|
||||
|
||||
it('serves cold ordinary history and validates every durable subagent descriptor state', async () => {
|
||||
@@ -538,18 +538,18 @@ describe('SessionHistoryController', () => {
|
||||
const missing = await setup()
|
||||
cold(missing.ctx, childHeader, [])
|
||||
await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
|
||||
.rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
|
||||
.rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } })
|
||||
|
||||
const corrupt = await setup()
|
||||
cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })])
|
||||
await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal()))
|
||||
.rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
|
||||
.rejects.toMatchObject({ code: 'subagent/catalog-diagnostic', details: { reason: 'corrupt' } })
|
||||
|
||||
const ordinaryChild = await setup()
|
||||
const { origin: _origin, ...ordinaryChildHeader } = childHeader
|
||||
cold(ordinaryChild.ctx, ordinaryChildHeader, [])
|
||||
await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
|
||||
.rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
|
||||
.rejects.toMatchObject({ code: 'subagent/unauthorized' })
|
||||
})
|
||||
|
||||
it('reports an unavailable descriptor when an observed child has no projection value', async () => {
|
||||
@@ -578,7 +578,7 @@ describe('SessionHistoryController', () => {
|
||||
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
|
||||
throughSeq: -1,
|
||||
}, signal())).rejects.toMatchObject({
|
||||
failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } },
|
||||
code: 'subagent/catalog-diagnostic', details: { reason: 'unsupported' },
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
{ "path": "../../session-query/session-query" },
|
||||
{ "path": "../../skill/skill" },
|
||||
{ "path": "../../subagent/subagent" },
|
||||
{ "path": "../../util/time" },
|
||||
{ "path": "../../typert/protocol" },
|
||||
{ "path": "../../typert/registry" },
|
||||
{ "path": "../../workspace/workspace" }
|
||||
|
||||
Reference in New Issue
Block a user