diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index 29736817e0..c818d507e7 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -108,7 +108,7 @@ describe('RemoteStream', () => { ], carrierFailed) await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ - isDSHRemoteGatewayError: true, + isDSHRemoteError: true, code: 'gateway/internal', message: 'isolated retry failed', details: {}, @@ -130,7 +130,7 @@ describe('RemoteStream', () => { }) await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ - isDSHRemoteGatewayError: true, + isDSHRemoteError: true, code: 'gateway/internal', message: 'generation exploded', }) diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index 69a9b934e1..da2361ad90 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -1,6 +1,7 @@ /** Session-specific adapters for Gateway-owned Remote stream lifecycles. */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { RemoteJournalStream, RemoteSnapshotStream, @@ -60,7 +61,11 @@ function toSessionJournalChange( return { ...change, entries: historyEntries(change.entries) } case 'append': { if (change.entry.type !== 'event') { - throw new Error('session live stream emitted a packed history record') + throw new RemoteError( + 'gateway/internal', + 'session live stream emitted a packed history record', + {}, + ) } return { type: 'append', diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 565dcf99bf..8d7e4319d3 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -99,6 +99,29 @@ describe('Session open', () => { expect(api.followStarts).toHaveLength(2) }) + it('lands a packed live record in openState=error instead of crashing the stream loop', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + expect(session.getSnapshot().openState).toBe('open') + + // The live tail may carry only events; a packed record breaks that contract. + await api.pushFollow(SID, { + type: 'chunks', + event: { + type: 'chunkrow/text-chunks', + seq: 6, + time: 6, + data: { turn: 1, step: 1, index: 0, texts: ['a'], dt: [] }, + }, + } as never) + + await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) + expect(session.getSnapshot().openError).toMatchObject({ + code: 'gateway/internal', message: 'session live stream emitted a packed history record', + }) + }) + it('lands a Gateway-marked stream failure in openState=error', async () => { const { api, session } = makeSession() api.onHistory = () => Promise.reject(new Error('socket died')) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 6e00845e35..c5f50ad7de 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + isRemoteFailure, RemoteStream, RemoteStreamCarrierError, type RemoteStreamOptions, @@ -181,7 +182,10 @@ describe('Session Client stream adapters', () => { await stream.open({}) await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() }) - expect(failed.mock.calls[0]?.[0]).toMatchObject({ + const violation: unknown = failed.mock.calls[0]?.[0] + expect(isRemoteFailure(violation)).toBe(true) + expect(violation).toMatchObject({ + code: 'gateway/internal', message: 'session live stream emitted a packed history record', }) await stream.dispose() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 943f60b85d..740cb44e2c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4608,7 +4608,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RemoteError', - declaration: 'export class RemoteError extends Error {\n readonly isDSHRemoteGatewayError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', + declaration: 'export class RemoteError extends Error {\n readonly isDSHRemoteError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', }, { name: 'RemoteErrorCode', diff --git a/packages/typert/protocol/src/remote-error.ts b/packages/typert/protocol/src/remote-error.ts index ccdd6cd1d8..8805d0e9d4 100644 --- a/packages/typert/protocol/src/remote-error.ts +++ b/packages/typert/protocol/src/remote-error.ts @@ -11,7 +11,7 @@ import type { RemoteErrorCode, RemoteErrorDetailsMap, RemoteFailure } from './ty */ export class RemoteError extends Error { /** Structural marker: cross-realm/bundle identification never uses instanceof. */ - readonly isDSHRemoteGatewayError: true = true + readonly isDSHRemoteError: true = true /** * @param code - stable failure code declared in {@link RemoteErrorDetailsMap}. @@ -38,8 +38,11 @@ export class RemoteError extends * @returns the failure when the marker matches, otherwise undefined. */ export function remoteErrorOf(value: unknown): RemoteFailure | undefined { - if (value instanceof Error - && (value as { isDSHRemoteGatewayError?: unknown }).isDSHRemoteGatewayError === true) { + // Structural, not instanceof: an Error thrown in another realm (iframe, VM) + // fails instanceof Error here, so the marker plus the code field is the test. + if (typeof value === 'object' && value !== null + && (value as { isDSHRemoteError?: unknown }).isDSHRemoteError === true + && typeof (value as { code?: unknown }).code === 'string') { return value as unknown as RemoteFailure } return undefined diff --git a/packages/util/time/src/invariant.ts b/packages/util/time/src/invariant.ts index 2a52f0ffb3..81ded26371 100644 --- a/packages/util/time/src/invariant.ts +++ b/packages/util/time/src/invariant.ts @@ -1,6 +1,6 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-time`. - * @module @deepseek-ai/dsh-time/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-util-time`. + * @module @deepseek-ai/dsh-util-time/invariant */ /* jscpd:ignore-start */