` plugin package (ui-workspace is a com
## New component checklist
-1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.
+1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
-3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
+3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery.
4. Tokens only in CSS; Chinese product copy; English comments.
5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.
diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json
index 929464fdc8..baafbd4456 100644
--- a/packages/client/connection/package.json
+++ b/packages/client/connection/package.json
@@ -29,6 +29,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
+ "@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts
index 7e9471bff8..a92db49899 100644
--- a/packages/client/connection/src/client/api.ts
+++ b/packages/client/connection/src/client/api.ts
@@ -6,10 +6,10 @@
// The ./api and ./client subpath exports are the browser-safe channels.
export type {
- ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
+ ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
- WorkspaceApi, WorkspaceId, WorkspaceView,
+ ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
@@ -37,6 +37,9 @@ export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
+/** Successful value returned by the connection-generation host handshake. */
+export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'>
+
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
/**
diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts
index cfa608a394..12202bf4b5 100644
--- a/packages/client/connection/src/client/connection.ts
+++ b/packages/client/connection/src/client/connection.ts
@@ -131,11 +131,15 @@ export class ConnectionController {
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
// (see ConnectionConfig.streamOpenTimeoutMs).
const timeout = new AbortController()
- await Promise.all([
+ const [description] = await Promise.all([
this.api.host.describe({}),
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
])
timeout.abort()
+ const descriptionResult = description.result
+ if (!descriptionResult.ok) {
+ throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`)
+ }
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
this.attempt = 0
this.emitState('connected')
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 70ce89677c..f149e28984 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -1,7 +1,7 @@
// FixtureApi: standalone UI development without a server. Real contract shape: unary takes
// RpcRequest and returns RpcResponse (echoing the rpcId); streams yield RpcRequest
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
-// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
+// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable);
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
// approval/question requests exercise replay and composer takeover with stable rpcIds.
@@ -19,6 +19,7 @@ import type {
ToolResultMessage,
UserMessage,
} from '@deepseek-ai/dsh-llm'
+import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
SessionEvent,
SessionId,
@@ -51,10 +52,10 @@ function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'u
return createUserMessage({ content, source })
}
-function assistantMessage(content: ContentBlock[]): AssistantMessage {
+function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMessage {
return createAssistantMessage({
content,
- source: { provider: 'fixture', model: 'fx-1' },
+ source: { provider: 'fixture', model },
})
}
@@ -330,6 +331,16 @@ function sid(id: string): SessionId {
return id as SessionId
}
+const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg=='
+const FIXTURE_IMAGE_REF: ImageAttachmentRef = {
+ attachmentId: 'fixture:image' as AttachmentIdType,
+ mediaType: 'image/png',
+ bytes: 247,
+ width: 160,
+ height: 90,
+ name: 'fixture-image.png',
+}
+
/** Deterministic provider billing attached to fixture assistant messages. */
function fixtureUsage(turn: number, step: number): TokenUsage {
return {
@@ -340,7 +351,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
}
}
-/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
+/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / context. */
function buildAlphaLog(): SessionEvent[] {
const events: Record[] = []
@@ -476,7 +487,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
- // Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
+ // Turn 73: todo_write sample — the TodoRow toolview in the flow plus the
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
// in_progress: this fixture chooses the parallel policy, so both surfaces
// must render a parallel plan rather than the first active item alone.
@@ -535,8 +546,32 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
+ // Turn 72: user and assistant images share one durable fixture object.
+ // The todo turn remains last so its standing projection stays visible.
+ push({ type: 'turn/start', data: { turn: 72 } })
+ push({
+ type: 'user/message',
+ surfaceOp: 'append',
+ data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]),
+ })
+ push({ type: 'step/start', data: { turn: 72, step: 0 } })
+ push({
+ type: 'assistant/message',
+ surfaceOp: 'append',
+ data: {
+ turn: 72,
+ step: 0,
+ message: assistantMessage(
+ [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }],
+ 'fx-vision',
+ ),
+ },
+ })
+ push({ type: 'step/end', data: { turn: 72, step: 0 } })
+ push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } })
+
const todoArgs = JSON.stringify({ todos: fixtureTodos })
- toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
+ toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -855,7 +890,6 @@ function estimateFixtureContent(blocks: readonly ContentBlock[]): number {
// ContentBlockMap is merge-extensible: this client graph sees only the
// base four members, but fixture turns do carry extended blocks at
// runtime, so the structural JSON fallback below is live code.
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- the type collapses without the out-of-graph merges (see above).
if (block.type === 'tool-result') {
return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD
}
@@ -1057,6 +1091,18 @@ function pageOf(
return { events, hasMore: start > 0 }
}
+/** Fixture mirror of host session-scoped attachment authorization. */
+function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean {
+ const visit = (value: unknown): boolean => {
+ if (Array.isArray(value)) return value.some(visit)
+ if (typeof value !== 'object' || value === null) return false
+ const record = value as Record
+ if (record.attachmentId === attachmentId) return true
+ return Object.values(record).some(visit)
+ }
+ return log.some(event => visit(event.data))
+}
+
/** Fixture mirror of first-party message extraction used by session-query. */
function searchBlockText(block: ContentBlock): string[] {
switch (block.type) {
@@ -1351,6 +1397,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
session.sessionId,
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
]))
+ const attachments = new Map([[
+ String(FIXTURE_IMAGE_REF.attachmentId),
+ { attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA },
+ ]])
/** Credential store double: set/unset flip the describe badge, values never read back. */
const fixtureCredentials = new Map([
// The assembled fixture represents an already-configured shipped
@@ -1368,7 +1418,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
])
let fixtureDefaultPreset = 'standard'
- const nextTurn = new Map([[sid('fx-alpha'), 60]])
+ const nextTurn = new Map([[sid('fx-alpha'), 74]])
let nextSession = 1
let nextRpc = 1
let attachedSessions = options.empty ? 0 : 1
@@ -2145,9 +2195,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// First accepted prompt appends events: the summary stops being blank.
summary.blank = false
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
+ const durable: ContentBlock[] = content.map((block) => {
+ if (block.type === 'text') return block
+ const attachment: ImageAttachmentRef = {
+ attachmentId: `fixture:${randomUuid()}` as AttachmentIdType,
+ mediaType: block.mediaType,
+ bytes: Math.max(
+ 1,
+ Math.floor(block.data.length * 3 / 4)
+ - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0),
+ ),
+ width: 160,
+ height: 90,
+ ...block.name === undefined ? {} : { name: block.name },
+ }
+ attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
+ return { type: 'image', attachment }
+ })
if (mode === 'steer' && replays.has(id)) {
// Steering: the durable user/message lands inside the current turn; the replay continues.
- append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
+ append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
return ok(request, { accepted: true as const })
}
const turn = nextTurn.get(id) ?? 0
@@ -2160,7 +2227,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
- append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
+ append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
// Capacity parallel of the host token-meter's request/context record:
// log-only, appended inside the open turn, and deduplicated against the
// route already recorded (the fixture never varies contextWindow).
@@ -2186,6 +2253,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
)
return ok(request, { accepted: true as const })
},
+ attachment: (request) => {
+ const stored = attachments.get(String(request.payload.attachmentId))
+ if (stored === undefined) {
+ return err(request, {
+ code: 'attachment-error',
+ message: 'fixture attachment missing',
+ details: { reason: 'ATTACHMENT_NOT_FOUND' },
+ })
+ }
+ if (!logReferencesAttachment(
+ logs.get(request.payload.sessionId) ?? [],
+ String(request.payload.attachmentId),
+ )) {
+ return err(request, {
+ code: 'attachment-error',
+ message: 'fixture attachment is not referenced by this session',
+ details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
+ })
+ }
+ return ok(request, stored)
+ },
updateQueue: request => err(request, {
code: 'queue-item-not-found',
message: 'fixture has no pending queue item',
@@ -2262,15 +2350,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
archivedSessionIds: [...archivedSessionIds],
}),
create: (request) => {
- const { path, name } = request.payload
- const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
- const existing = workspaces.find(w => w.path === target)
+ const { path } = request.payload
+ const existing = workspaces.find(w => w.path === path)
if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false })
const now = new Date().toISOString()
const created: WorkspaceView = {
workspaceId: wid(`fx-ws-${nextWorkspace++}`),
- path: target,
- title: name ?? target.split('/').filter(Boolean).at(-1) ?? target,
+ path,
+ title: path.split('/').filter(Boolean).at(-1) ?? path,
sessionIds: [],
createdAt: now,
updatedAt: now,
@@ -2837,6 +2924,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.rename': return this.api.sessions.rename(request)
case 'session.fork': return this.api.sessions.fork(request)
case 'session.prompt': return this.api.sessions.prompt(request)
+ case 'session.attachment': return this.api.sessions.attachment(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'subagent.list': return this.api.subagents.list(request)
diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts
index e14a0764a8..0788e4508e 100644
--- a/packages/client/connection/src/client/index.ts
+++ b/packages/client/connection/src/client/index.ts
@@ -14,7 +14,7 @@ import type { ClientConnectionRpc } from '../rpc.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
- ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
+ ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -24,7 +24,7 @@ export type {
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
- IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
+ HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts
index cdf8d12bfe..9c9e739987 100644
--- a/packages/client/connection/src/http-bridge.ts
+++ b/packages/client/connection/src/http-bridge.ts
@@ -21,8 +21,14 @@ export interface FetchHandler {
* @param req - incoming node:http request (fully read before dispatch).
* @param res - node:http response the bridge writes and owns to completion.
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
+ * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
*/
-export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise {
+export async function bridge(
+ req: IncomingMessage,
+ res: ServerResponse,
+ apiHandler: FetchHandler,
+ maxRequestBodyBytes = 32 * 1024 * 1024,
+): Promise {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
@@ -32,8 +38,26 @@ export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandl
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
+ const declaredLength = req.headers['content-length']
+ if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
+ res.writeHead(413, { connection: 'close' })
+ res.end()
+ req.destroy()
+ return
+ }
const chunks: Buffer[] = []
- for await (const chunk of req) chunks.push(chunk as Buffer)
+ let received = 0
+ for await (const chunk of req) {
+ const buffer = chunk as Buffer
+ received += buffer.byteLength
+ if (received > maxRequestBodyBytes) {
+ res.writeHead(413, { connection: 'close' })
+ res.end()
+ req.destroy()
+ return
+ }
+ chunks.push(buffer)
+ }
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
requests; the fields are only optional on the client-side IncomingMessage type */
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {
diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts
index f865653b9f..b2189501cb 100644
--- a/packages/client/connection/src/index.ts
+++ b/packages/client/connection/src/index.ts
@@ -1,6 +1,7 @@
/** Host HTTP bridge for browser-client RPC. */
import type { Context } from 'cordis'
import z from 'schemastery'
+import type {} from '@deepseek-ai/dsh-attachment'
// Activates the httpServer Context merge used below.
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
@@ -25,6 +26,25 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */
export const name = 'client-connection'
+/** Headroom for RPC JSON fields around aggregate base64 image payloads. */
+const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
+
+function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
+ const attachments = ctx.get('attachments')
+ if (attachments === undefined) return
+ const requiredImageBodyBytes = Math.ceil(
+ attachments.imageLimits.maxMessageImageBytes * 4 / 3,
+ ) + REQUEST_ENVELOPE_HEADROOM_BYTES
+ if (maxRequestBodyBytes < requiredImageBodyBytes) {
+ throw new Error(
+ `client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
+ + `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
+ )
+ }
+}
+/** Default carrier cap for all HTTP RPC bodies. */
+const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
+
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
export const inject = ['httpServer']
@@ -39,10 +59,13 @@ export interface ConnectionConfig {
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
+ /** Maximum buffered JSON body for every `/api` request. */
+ maxRequestBodyBytes?: number
}
export const Config: z = z.object({
trustedHosts: z.array(String).default([]),
+ maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
})
/**
@@ -109,9 +132,11 @@ const PRIVILEGED_METHODS = new Set([
export function apply(ctx: Context, config?: ConnectionConfig): void {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
+ const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
+ if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes)
const connection = new HostConnectionService(ctx, trustedHosts)
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
async fetch(request) {
@@ -144,11 +169,12 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
res.end('forbidden')
return
}
- await bridge(req, res, fetchHandler)
+ await bridge(req, res, fetchHandler, maxRequestBodyBytes)
},
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
ctx.inject(['apiProxy'], (apiCtx) => {
+ assertImageBodyCapacity(apiCtx, maxRequestBodyBytes)
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
const registerDownlink = (
path: string,
diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts
index 4de4a31f25..26efec982a 100644
--- a/packages/client/connection/tests/connection.spec.ts
+++ b/packages/client/connection/tests/connection.spec.ts
@@ -83,6 +83,35 @@ describe('connection lifecycle', () => {
}
})
+ it('treats a host.describe business error as generation failure', async () => {
+ const api = new FakeApiClient()
+ let describeCalls = 0
+ api.onDescribe = () => {
+ describeCalls += 1
+ if (describeCalls === 1) {
+ return Promise.resolve({
+ rpcId: 'bad-describe' as never,
+ result: {
+ ok: false as const,
+ error: { code: 'internal' as const, message: 'not ready', details: {} },
+ },
+ })
+ }
+ return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
+ }
+ let connected = 0
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
+ controller.start()
+ try {
+ await vi.waitFor(() => { expect(describeCalls).toBe(2) })
+ await vi.waitFor(() => { expect(connected).toBe(1) })
+ } finally {
+ controller.stop()
+ warnSpy.mockRestore()
+ }
+ })
+
it('converges stream/error frames into reconnect instead of dispatching them', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []
diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts
index fc6ba9a57d..f1e62c618a 100644
--- a/packages/client/connection/tests/fake-api.ts
+++ b/packages/client/connection/tests/fake-api.ts
@@ -67,6 +67,8 @@ export class FakeApiClient implements IApiClient {
=> Promise