mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
Merge remote-tracking branch 'origin/master' into xtr/durable-inbox-recovery
# Conflicts: # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # packages/api/session-controller/src/control.ts # packages/context/agent-instructions/tests/agent-instructions.spec.ts # packages/test-support/agent-loop-testkit/package.json
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
|
||||
README.md: edc31cc79df949e4657895a2a1751ca118782253
|
||||
README.zh.md: cc1617a07c7a880b6f398df89eddd80b89105a42
|
||||
README.md: a193eb41fedd94152eb004f4882b4fe638300b42
|
||||
README.zh.md: 1a24e708f181d85f431ce0d8133f45c1a10ebff7
|
||||
|
||||
@@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa
|
||||
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame.
|
||||
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The prompt's `requestId` is the correlation identity — the Host already echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the transcript node is), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only — reload and reconnect rebuild the conversation from durable events alone.
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。
|
||||
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。prompt 的 `requestId` 就是关联标识,Host 本就把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休(该延迟保证 transcript 节点可渲染之前回显仍在),带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存,刷新与重连只从 durable event 重建会话。
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-session-controller",
|
||||
"description": "Session Remote commands, cold reads, and live control transport",
|
||||
"version": "0.1.2-alpha.1",
|
||||
"version": "0.1.2-alpha.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -73,6 +73,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-deque": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -83,7 +85,6 @@
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
@@ -96,18 +97,25 @@
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^"
|
||||
"@deepseek-ai/dsh-util-time": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-jobs": { "optional": true },
|
||||
"@deepseek-ai/dsh-session-persistence": { "optional": true },
|
||||
"@deepseek-ai/dsh-session-projection-cache": { "optional": true }
|
||||
"@deepseek-ai/dsh-jobs": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-projection-cache": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
@@ -118,7 +126,6 @@
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
@@ -133,8 +140,8 @@
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-json": "workspace:^",
|
||||
@@ -142,7 +149,8 @@
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^"
|
||||
"@deepseek-ai/dsh-util-time": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "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'
|
||||
|
||||
/**
|
||||
@@ -28,6 +27,8 @@ export type PendingSubmissionRetirement =
|
||||
|
||||
/** Input registering one local submission echo ahead of its prompt call. */
|
||||
export interface BeginSubmissionInput {
|
||||
/** Delivery mode used with the upcoming prompt. */
|
||||
readonly mode: 'queue' | 'steer'
|
||||
/** Prompt text exactly as the upcoming prompt will send it. */
|
||||
readonly text: string
|
||||
/** Ordered image previews matching the upcoming prompt's image parts. */
|
||||
@@ -84,7 +85,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 +93,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 {
|
||||
@@ -30,6 +30,9 @@ export interface PendingSubmissionImage {
|
||||
readonly height?: number
|
||||
}
|
||||
|
||||
/** Client surface selected when a local submission begins. */
|
||||
export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering'
|
||||
|
||||
/**
|
||||
* One local prompt-submission echo: inserted synchronously when a submission
|
||||
* begins, so the conversation can show the message before serialization,
|
||||
@@ -39,6 +42,8 @@ export interface PendingSubmissionImage {
|
||||
export interface PendingSubmission {
|
||||
/** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */
|
||||
readonly requestId: SessionRequestId
|
||||
/** Expected surface until the Host reports the admitted queue or durable occurrence. */
|
||||
readonly placement: PendingSubmissionPlacement
|
||||
/** Client wall-clock ms when the submission began. */
|
||||
readonly time: number
|
||||
/** Prompt text exactly as it will be sent (one text block). */
|
||||
@@ -53,7 +58,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 +75,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,
|
||||
@@ -62,11 +60,11 @@ export type {
|
||||
OpenState,
|
||||
PendingSubmission,
|
||||
PendingSubmissionImage,
|
||||
PendingSubmissionPlacement,
|
||||
PromptError,
|
||||
QueuedMessage,
|
||||
SessionSnapshot,
|
||||
} from './contract/snapshot.ts'
|
||||
export type { ClientFailure, ClientResult } from './contract/result.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -75,9 +73,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 +87,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 +107,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
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts'
|
||||
|
||||
const QUEUE_PREVIEW_CHARS = 200
|
||||
|
||||
// Image blocks are excluded: queue presentation renders them as thumbnails
|
||||
// from `content`, so the text preview covers only what has no visual form.
|
||||
function previewOf(content: readonly ContentBlock[]): string {
|
||||
const flat = content
|
||||
.filter(block => block.type !== 'image')
|
||||
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
|
||||
.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const chars = Array.from(flat)
|
||||
|
||||
@@ -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. */
|
||||
@@ -189,6 +184,9 @@ export class Session implements SessionFace {
|
||||
const requestId = randomUUID() as SessionRequestId
|
||||
this.pendingSubmissions = [...this.pendingSubmissions, {
|
||||
requestId,
|
||||
placement: this.running
|
||||
? input.mode === 'steer' ? 'steering' : 'queued'
|
||||
: 'transcript',
|
||||
time: Date.now(),
|
||||
text: input.text,
|
||||
images: input.images,
|
||||
@@ -214,7 +212,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 +221,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 +271,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 +319,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 +330,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 +360,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 +540,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 +655,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 +716,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,11 @@
|
||||
/** 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 { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import {
|
||||
RemoteJournalStream,
|
||||
RemoteSnapshotStream,
|
||||
RemoteStreamCarrierError,
|
||||
RemoteStreamError,
|
||||
type ClientRemote,
|
||||
type RemoteJournalChange,
|
||||
type RemoteJournalFrame,
|
||||
@@ -25,6 +24,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,
|
||||
@@ -61,7 +61,11 @@ function toSessionJournalChange(
|
||||
return { ...change, entries: historyEntries(change.entries) }
|
||||
case 'append': {
|
||||
if (change.entry.type !== 'event') {
|
||||
throw new Error('session live stream emitted a packed history record')
|
||||
throw new RemoteError(
|
||||
'gateway/internal',
|
||||
'session live stream emitted a packed history record',
|
||||
{},
|
||||
)
|
||||
}
|
||||
return {
|
||||
type: 'append',
|
||||
@@ -80,8 +84,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 +111,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 +144,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 +200,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 +211,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 }
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
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 { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import {
|
||||
ReasoningEffortId, createUserMessage, freezeMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, SessionHeader, SessionId, 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()}`)
|
||||
const sessionId = request.sessionId ?? brandString<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,13 +230,13 @@ 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)}`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
const childId = SessionId(`session-${randomUUID()}`)
|
||||
const childId = brandString<SessionId>(`session-${randomUUID()}`)
|
||||
const composition = await this.agents.composeAgent(this.agents.presetForObservation(source))
|
||||
try {
|
||||
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
|
||||
@@ -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,23 +312,23 @@ 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' },
|
||||
)
|
||||
}
|
||||
}
|
||||
const content = await durablePromptContent(this.ctx, request.content)
|
||||
const content = await admitPromptContent(this.ctx.attachments, request.content)
|
||||
const message: UserMessage = createUserMessage({ content, source })
|
||||
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,29 +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][],
|
||||
): Promise<ContentBlock[]> {
|
||||
if (content.every(part => part.type === 'text')) {
|
||||
return content.map(part => ({ type: 'text', text: part.text }))
|
||||
}
|
||||
const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image'))
|
||||
let next = 0
|
||||
return content.map(part => part.type === 'text'
|
||||
? { type: 'text', text: part.text }
|
||||
// admitEncodedImages returns one reference per image part in order.
|
||||
: { type: 'image', attachment: refs[next++] as ImageAttachmentRef })
|
||||
}
|
||||
|
||||
function imageBlockIn(
|
||||
content: unknown,
|
||||
match: (ref: ImageAttachmentRef) => boolean,
|
||||
@@ -580,18 +546,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)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent, InboxState } from '@deepseek-ai/dsh-agent'
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
||||
import type {
|
||||
JsonValue, Session, SessionId, UserMessage,
|
||||
Session, SessionId, UserMessage,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type {
|
||||
SessionControlBaseline,
|
||||
SessionControlFrame,
|
||||
@@ -125,13 +127,13 @@ export class SessionControlController {
|
||||
}
|
||||
|
||||
class ControlQueue {
|
||||
private readonly buffer: SessionControlFrame[] = []
|
||||
private readonly buffer = new Deque<SessionControlFrame>()
|
||||
private wake: (() => void) | undefined
|
||||
private done = false
|
||||
|
||||
push(frame: SessionControlFrame): void {
|
||||
if (this.done) return
|
||||
this.buffer.push(frame)
|
||||
this.buffer.pushBack(frame)
|
||||
const wake = this.wake
|
||||
this.wake = undefined
|
||||
wake?.()
|
||||
@@ -150,14 +152,14 @@ class ControlQueue {
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
while (!this.done && !signal.aborted) {
|
||||
const frame = this.buffer.shift()
|
||||
const frame = this.buffer.popFront()
|
||||
if (frame !== undefined) {
|
||||
yield frame
|
||||
continue
|
||||
}
|
||||
await new Promise<void>((resolve) => { this.wake = resolve })
|
||||
}
|
||||
while (this.buffer.length > 0 && !signal.aborted) yield this.buffer.shift() as SessionControlFrame
|
||||
while (this.buffer.size > 0 && !signal.aborted) yield this.buffer.popFront() as SessionControlFrame
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
this.end()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/** Cold Session history pagination and live-event source. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
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 +56,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,
|
||||
@@ -88,7 +89,7 @@ export class SessionHistoryController {
|
||||
validateFollowRequest(request)
|
||||
const { address } = request
|
||||
const target = addressId(address)
|
||||
const buffered: SessionEvent[] = []
|
||||
const buffered = new Deque<SessionEvent>()
|
||||
let snapshotCursor: number | undefined
|
||||
let wake: (() => void) | undefined
|
||||
const notify = (): void => {
|
||||
@@ -104,7 +105,7 @@ export class SessionHistoryController {
|
||||
this.closeFollowers.add(close)
|
||||
const disposeEvent = this.ctx.on('session/event', (session, event) => {
|
||||
if (session.id !== target) return
|
||||
buffered.push(event)
|
||||
buffered.pushBack(event)
|
||||
notify()
|
||||
}, { global: true })
|
||||
const disposeCreated = this.ctx.on('session/created', (session) => {
|
||||
@@ -115,7 +116,9 @@ export class SessionHistoryController {
|
||||
const suffix = session.events.slice(snapshotCursor === undefined
|
||||
? session.firstLiveSeq
|
||||
: snapshotCursor + 1)
|
||||
buffered.unshift(...suffix)
|
||||
for (let index = suffix.length - 1; index >= 0; index -= 1) {
|
||||
buffered.pushFront(suffix[index] as SessionEvent)
|
||||
}
|
||||
notify()
|
||||
}, { global: true })
|
||||
const onAbort = (): void => { notify() }
|
||||
@@ -148,14 +151,14 @@ export class SessionHistoryController {
|
||||
}
|
||||
let nextSeq = cursor + 1
|
||||
while (!follower.closed && !signal.aborted) {
|
||||
const item = buffered.shift()
|
||||
const item = buffered.popFront()
|
||||
if (item === undefined) {
|
||||
await new Promise<void>((resolve) => { wake = resolve })
|
||||
continue
|
||||
}
|
||||
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 +214,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 +244,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 +279,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)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/** Session Controller events forwarded unchanged through the Remote Event carrier. */
|
||||
export const SESSION_CONTROLLER_REMOTE_EVENTS = [
|
||||
'api-session/activity',
|
||||
'api-session/added',
|
||||
'api-session/error',
|
||||
'api-session/removed',
|
||||
'api-session/status',
|
||||
] as const
|
||||
/** Session Controller events available to a Remote Event assembly. */
|
||||
type SessionControllerRemoteEvent =
|
||||
| 'api-session/activity'
|
||||
| 'api-session/added'
|
||||
| 'api-session/error'
|
||||
| 'api-session/removed'
|
||||
| 'api-session/status'
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertRemoteEventSelection extends
|
||||
Record<typeof SESSION_CONTROLLER_REMOTE_EVENTS[number], true> {}
|
||||
Record<SessionControllerRemoteEvent, true> {}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,9 +7,10 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type { JsonValue, SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
@@ -174,55 +175,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
|
||||
@@ -424,6 +409,7 @@ export interface SessionWireEvent {
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
readonly data: JsonValue
|
||||
readonly ignorable?: true
|
||||
readonly sourceEventSeqs?: number[]
|
||||
readonly surfaceOp?: SurfaceOp
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -57,18 +57,22 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
|
||||
return () => { generationListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} },
|
||||
rpc: {
|
||||
call: () => Promise.reject(new Error('unexpected generic RPC call')),
|
||||
},
|
||||
reconnect: () => {},
|
||||
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)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import type { PromptContentPart as AttachmentPromptContentPart } from '@deepseek-ai/dsh-attachment/types'
|
||||
import {
|
||||
MutableSessionEventSource, type SessionLiveEventEntry,
|
||||
} from '../src/client/contract/events.ts'
|
||||
import { transportResult } from '../src/client/contract/result.ts'
|
||||
import type { PromptContentPart as SessionPromptContentPart } from '../src/types.ts'
|
||||
|
||||
function entry(seq: number): SessionLiveEventEntry {
|
||||
return {
|
||||
@@ -17,6 +18,10 @@ function entry(seq: number): SessionLiveEventEntry {
|
||||
}
|
||||
|
||||
describe('Client Session contracts', () => {
|
||||
it('keeps its catalog-visible prompt parts identical to attachment intake', () => {
|
||||
expectTypeOf<SessionPromptContentPart>().toEqualTypeOf<AttachmentPromptContentPart>()
|
||||
})
|
||||
|
||||
it('publishes exact replace, prepend, and append event-window changes', () => {
|
||||
const feed = new MutableSessionEventSource()
|
||||
const listener = vi.fn()
|
||||
@@ -76,14 +81,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')
|
||||
|
||||
@@ -66,7 +66,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', () => {
|
||||
@@ -89,21 +89,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,
|
||||
@@ -124,7 +124,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()
|
||||
@@ -170,11 +170,11 @@ describe('Session attachment authorization', () => {
|
||||
const inserted = imageRef('inserted')
|
||||
const streamed = imageRef('streamed')
|
||||
const events = [
|
||||
event('fixture/direct', 0, {
|
||||
{ ...event('fixture/direct', 0, {
|
||||
content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
|
||||
type: 'tool-result', content: [{ type: 'image', attachment: nested }],
|
||||
}],
|
||||
}),
|
||||
}), ignorable: true as const },
|
||||
{ ...event('assistant/message', 1, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -219,7 +219,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)
|
||||
@@ -235,7 +235,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'),
|
||||
@@ -249,7 +249,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()
|
||||
}
|
||||
})
|
||||
@@ -267,7 +267,7 @@ describe('Session attachment authorization', () => {
|
||||
|
||||
await expectFailure(controller.attachment({
|
||||
sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
|
||||
}), 'internal')
|
||||
}), 'gateway/internal')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,6 @@ async function harness(withJobs: boolean): Promise<{
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
if (withJobs) {
|
||||
await ctx.plugin(LocalJobRegistry)
|
||||
ctx.jobs.attachController('session-controller-test')
|
||||
|
||||
@@ -20,7 +20,6 @@ async function harness(): Promise<{
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const session = ctx.sessions.create(SessionId('queue-session'))
|
||||
const agent: Agent = {
|
||||
id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'running', ctx,
|
||||
@@ -92,9 +91,9 @@ describe('Session control queue projection', () => {
|
||||
it('derives queue replacements from the completed projection regardless of registration order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const control = new SessionControlController(ctx)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const session = ctx.sessions.create(SessionId('late-projection-queue'))
|
||||
const agent: Agent = {
|
||||
id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'running', ctx,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('Session queue snapshot intake', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('marks mixed-content messages non-editable while retaining their preview', () => {
|
||||
it('marks mixed-content messages non-editable and keeps image blocks out of the text preview', () => {
|
||||
const session = makeSession()
|
||||
session.handleControlFrame(queueFrame([{
|
||||
id: 'q-image',
|
||||
@@ -86,7 +86,9 @@ describe('Session queue snapshot intake', () => {
|
||||
{
|
||||
id: 'q-image', placement: 'queued',
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
|
||||
preview: 'hi [image]', text: null,
|
||||
// Image blocks render as thumbnails from `content`, so the preview
|
||||
// carries only the text; non-image foreign blocks keep their marker.
|
||||
preview: 'hi', text: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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,12 +221,64 @@ 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()
|
||||
})
|
||||
|
||||
it('delivers an admitted image batch through steer with the same ordered content as queue', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const attachments = {
|
||||
imageLimits: {
|
||||
maxImageBytes: 4,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 4,
|
||||
maxImagePixels: 4,
|
||||
maxImageDimension: 2000,
|
||||
mediaTypes: ['image/png'],
|
||||
},
|
||||
validateImage: vi.fn(() => Promise.resolve()),
|
||||
saveImage: vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
|
||||
attachmentId: `att-${String(input.data[0])}`,
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
...input.name === undefined ? {} : { name: input.name },
|
||||
})),
|
||||
}
|
||||
ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never)
|
||||
const steer = vi.fn()
|
||||
const followup = vi.fn()
|
||||
Object.assign(agent, { steer, followup })
|
||||
const remote = createSessionTestRemote(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
|
||||
cwd: '/tmp',
|
||||
})
|
||||
|
||||
const result = await remote.prompt(promptRequest({
|
||||
sessionId,
|
||||
mode: 'steer' as const,
|
||||
content: [
|
||||
{ type: 'text' as const, text: 'look at this' },
|
||||
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'mid-turn.png' },
|
||||
],
|
||||
}))
|
||||
expect(result.ok).toBe(true)
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
expect((steer.mock.calls[0]?.[0] as UserMessage).content).toEqual([
|
||||
{ type: 'text', text: 'look at this' },
|
||||
{
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'mid-turn.png',
|
||||
},
|
||||
},
|
||||
])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('allows a text-only selection while durable or pending images remain available for later models', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
registerTextOnly(ctx)
|
||||
@@ -294,7 +342,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 +471,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 +481,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 +493,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 +609,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 +669,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 +689,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 +699,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 +727,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'
|
||||
@@ -69,21 +70,38 @@ describe('beginSubmission', () => {
|
||||
const { session } = makeSession()
|
||||
expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false })
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '你好',
|
||||
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
|
||||
})
|
||||
expect(session.getSnapshot().promptAttempted).toBe(true)
|
||||
expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
|
||||
requestId: handle.requestId,
|
||||
placement: 'transcript',
|
||||
text: '你好',
|
||||
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
|
||||
}])
|
||||
})
|
||||
|
||||
it('derives and captures the echo placement from running state and delivery mode', () => {
|
||||
const { session } = makeSession()
|
||||
session.beginSubmission({ mode: 'queue', text: '空闲', images: [] })
|
||||
session.handleRunning(true)
|
||||
session.beginSubmission({ mode: 'queue', text: '排队', images: [] })
|
||||
session.beginSubmission({ mode: 'steer', text: '纠偏', images: [] })
|
||||
session.handleRunning(false)
|
||||
expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([
|
||||
{ text: '空闲', placement: 'transcript' },
|
||||
{ text: '排队', placement: 'queued' },
|
||||
{ text: '纠偏', placement: 'steering' },
|
||||
])
|
||||
})
|
||||
|
||||
it('abandon retires the echo as failed exactly once', () => {
|
||||
const { session } = makeSession()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '放弃',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
@@ -98,9 +116,10 @@ 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({
|
||||
mode: 'queue',
|
||||
text: '失败的',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
@@ -114,15 +133,15 @@ describe('prompt-coupled retirement', () => {
|
||||
|
||||
it('sends the echo identity as the prompt requestId', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const handle = session.beginSubmission({ text: '带 id', images: [] })
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: '带 id', images: [] })
|
||||
await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
|
||||
expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
|
||||
})
|
||||
|
||||
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' } }))
|
||||
session.beginSubmission({ text: '还在', images: [] })
|
||||
api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
|
||||
session.beginSubmission({ mode: 'queue', text: '还在', images: [] })
|
||||
await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
})
|
||||
@@ -135,6 +154,7 @@ describe('observed retirement', () => {
|
||||
await session.open()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '发送',
|
||||
images: [{ previewUrl: 'blob:p1' }],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
@@ -152,7 +172,9 @@ describe('observed retirement', () => {
|
||||
it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => {
|
||||
const { session } = makeSession()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
session.handleRunning(true)
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '排队',
|
||||
images: [{ previewUrl: 'blob:p1' }],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
@@ -168,7 +190,7 @@ describe('observed retirement', () => {
|
||||
|
||||
it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const handle = session.beginSubmission({ text: '重连', images: [] })
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: '重连', images: [] })
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)])))
|
||||
await session.open()
|
||||
await settleFrames()
|
||||
@@ -181,6 +203,7 @@ describe('observed retirement', () => {
|
||||
await session.open()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '先观察',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
@@ -197,6 +220,7 @@ describe('observed retirement', () => {
|
||||
await session.open()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '同一请求',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
@@ -221,7 +245,7 @@ describe('observed retirement', () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([])))
|
||||
await session.open()
|
||||
const handle = session.beginSubmission({ text: '帧', images: [] })
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: '帧', images: [] })
|
||||
await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never })
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
expect(frames).toHaveLength(1)
|
||||
@@ -237,11 +261,13 @@ describe('disposal', () => {
|
||||
await session.open()
|
||||
const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = []
|
||||
const observed = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '已观察',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push({ text: '已观察', retirement }),
|
||||
})
|
||||
session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '未settle',
|
||||
images: [],
|
||||
onRetire: retirement => retirements.push({ text: '未settle', retirement }),
|
||||
|
||||
@@ -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,12 @@
|
||||
/** 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 { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/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 +77,57 @@ 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('lands exhausted carrier retries in openState=error as gateway/internal', async () => {
|
||||
const { api, session } = makeSession()
|
||||
// Two consecutive carrier losses before any opening is accepted exhaust the
|
||||
// Gateway's retry budget; the escaping failure crosses the stream boundary marked.
|
||||
api.onHistory = () => Promise.reject(new RemoteStreamCarrierError('history carrier down'))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().openState).toBe('error')
|
||||
expect(session.getSnapshot().openError).toMatchObject({
|
||||
code: 'gateway/internal', message: 'history carrier down',
|
||||
})
|
||||
expect(api.followStarts).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('lands a packed live record in openState=error instead of crashing the stream loop', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().openState).toBe('open')
|
||||
|
||||
// The live tail may carry only events; a packed record breaks that contract.
|
||||
await api.pushFollow(SID, {
|
||||
type: 'chunks',
|
||||
event: {
|
||||
type: 'chunkrow/text-chunks',
|
||||
seq: 6,
|
||||
time: 6,
|
||||
data: { turn: 1, step: 1, index: 0, texts: ['a'], dt: [] },
|
||||
},
|
||||
} as never)
|
||||
|
||||
await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
|
||||
expect(session.getSnapshot().openError).toMatchObject({
|
||||
code: 'gateway/internal', message: 'session live stream emitted a packed history record',
|
||||
})
|
||||
})
|
||||
|
||||
it('lands a Gateway-marked stream failure in openState=error', 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' })
|
||||
expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'socket died' })
|
||||
})
|
||||
|
||||
it('stitches live frames arriving while history is pending, dropping the page overlap', async () => {
|
||||
@@ -281,25 +318,52 @@ describe('prompt and cancel errors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards continuation image parts to the subagent prompt Remote unstripped', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, fakeRemote(api), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await session.open()
|
||||
const content = [
|
||||
{ type: 'text' as const, text: '看这张图' },
|
||||
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'aGk=', name: 'shot.png' },
|
||||
]
|
||||
const prompted = await session.prompt(content, 'queue')
|
||||
|
||||
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('subagents.prompt')).toEqual([
|
||||
{
|
||||
requestId: expect.any(String) as unknown as string,
|
||||
parentSessionId: PARENT, childSessionId: SID,
|
||||
mode: 'continuable',
|
||||
content,
|
||||
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
])
|
||||
expect(session.getSnapshot().promptError).toBeNull()
|
||||
})
|
||||
|
||||
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 +371,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 +389,26 @@ 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 without narrowing its upload parts', async () => {
|
||||
const api = new FakeApiClient()
|
||||
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).toEqual({ ok: true, value: { accepted: true } })
|
||||
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 +437,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 +485,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 +516,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 +571,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 +589,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 +599,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 +624,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),
|
||||
{},
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isRemoteFailure,
|
||||
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 +74,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')),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +182,10 @@ describe('Session Client stream adapters', () => {
|
||||
|
||||
await stream.open({})
|
||||
await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() })
|
||||
expect(failed.mock.calls[0]?.[0]).toMatchObject({
|
||||
const violation: unknown = failed.mock.calls[0]?.[0]
|
||||
expect(isRemoteFailure(violation)).toBe(true)
|
||||
expect(violation).toMatchObject({
|
||||
code: 'gateway/internal',
|
||||
message: 'session live stream emitted a packed history record',
|
||||
})
|
||||
await stream.dispose()
|
||||
@@ -295,7 +305,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 +316,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()
|
||||
|
||||
@@ -30,6 +30,7 @@ function event(type: string, seq: number, data: unknown = {}): SessionEvent {
|
||||
seq,
|
||||
time: seq + 1,
|
||||
data,
|
||||
...type.startsWith('fixture/') ? { ignorable: true } : {},
|
||||
} as SessionEvent
|
||||
}
|
||||
|
||||
@@ -296,7 +297,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 +397,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 +439,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 +456,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 +464,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 +472,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 +481,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 +495,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 +505,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 +539,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 +579,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()
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
{ "path": "../../interaction/permission-presets" },
|
||||
{ "path": "../../jobs/jobs" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../util/deque" },
|
||||
{ "path": "../../util/native-command" },
|
||||
{ "path": "../../preset/agent-presets" },
|
||||
{ "path": "../../runtime-diagnostics/invariants" },
|
||||
@@ -42,6 +43,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