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

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