Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/README.md
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/src/client/stores.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/runtime/package.json
#	packages/host/runtime/src/boot.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/host/runtime/tsconfig.json
#	packages/host/webserver/README.md
#	packages/host/webserver/src/index.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-25 22:35:22 +08:00
1264 changed files with 37694 additions and 29428 deletions

View File

@@ -0,0 +1,8 @@
/**
* The /api URL prefix — single source for both halves of the web transport.
* The node half registers this prefix on the web server; browser-side path
* literals currently live in the apiproxy client layer (out of scope here).
*/
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
export const API_PATH = '/api'

View File

@@ -8,7 +8,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ResponseValue,
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -11,7 +11,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView,
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
import { AbstractApiClient, RpcId } from './api.ts'
@@ -287,6 +287,20 @@ interface StreamConn<F> {
push(envelope: RpcRequest<F>): void
}
/** Deterministic fixture branches used by keyless Web assembly tests. */
export interface FixtureOptions {
/** Start with no real Workspace or Session. */
empty?: boolean
/** Reject every prompt before appending its user event. */
rejectPrompt?: boolean
/** Publish the Session but fail its Workspace account write. */
failWorkspaceAttach?: boolean
/** Publish and frame the Session, then throw instead of returning create. */
dropSessionCreateResponse?: boolean
/** Order of the two successful create frames. */
createFrameOrder?: 'session-first' | 'workspace-first'
}
/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
* outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
* piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
@@ -331,10 +345,11 @@ class FxInbox<F> implements StreamConn<F> {
/**
* In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material).
* @param options - fixture branches for empty state and failure timing.
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
*/
export function createFixtureApi(): ApiProxy {
const sessions: SessionSummary[] = [
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const sessions: SessionSummary[] = options.empty ? [] : [
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
{ sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
@@ -347,6 +362,20 @@ export function createFixtureApi(): ApiProxy {
]])
let nextSession = 1
let nextRpc = 1
let attachedSessions = options.empty ? 0 : 1
// Workspace entities mirroring the host registry: the fixture sessions all
// live under one workspace, whose account carries them in attach order.
const wid = (raw: string): WorkspaceId => raw as WorkspaceId
const fixtureEpoch = new Date(Date.now() - 300_000).toISOString()
const workspaces: WorkspaceView[] = options.empty ? [] : [{
workspaceId: wid('fx-ws-fixture'),
path: '/tmp/fixture',
title: 'fixture',
sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')],
createdAt: fixtureEpoch,
updatedAt: fixtureEpoch,
}]
let nextWorkspace = 1
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
const pendingApprovalRpcId = mint()
@@ -513,12 +542,71 @@ export function createFixtureApi(): ApiProxy {
return {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
create: (request) => {
create: async (request) => {
const workspace = request.payload.workspaceId === undefined
? undefined
: workspaces.find(w => w.workspaceId === request.payload.workspaceId)
if (request.payload.workspaceId !== undefined && workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${request.payload.workspaceId}`,
details: { workspaceId: request.payload.workspaceId },
})
}
const cwd = workspace?.path ?? request.payload.cwd ?? '/tmp/fixture'
const requestedId = request.payload.sessionId
const attachWorkspace = (sessionId: SessionId): void => {
/* v8 ignore next -- callers enter only when a target Workspace exists. */
if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return
workspace.sessionIds = [sessionId, ...workspace.sessionIds]
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
const attachFailure = (
sessionId: SessionId,
workspaceId: WorkspaceId,
): Promise<RpcResponse<{ sessionId: SessionId }>> => err(request, {
code: 'workspace-attach-failed' as const,
message: `fixture rejected Workspace attachment for ${sessionId}`,
details: { sessionId, workspaceId },
})
if (requestedId !== undefined) {
const existing = summaryOf(requestedId)
if (existing !== undefined) {
if (existing.cwd !== cwd) {
return err(request, {
code: 'session-conflict',
message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`,
details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } },
})
}
if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) {
if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId)
attachWorkspace(requestedId)
}
return ok(request, { sessionId: requestedId })
}
}
const created: SessionSummary = {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd,
}
sessions.push(created)
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
attachedSessions += 1
const emitSession = (): void => {
emitHost({ type: 'host/session-added', sessionId: created.sessionId, cwd })
}
if (workspace !== undefined && options.failWorkspaceAttach) {
emitSession()
return attachFailure(created.sessionId, workspace.workspaceId)
}
if (workspace !== undefined && options.createFrameOrder === 'workspace-first') {
attachWorkspace(created.sessionId)
emitSession()
} else {
emitSession()
if (workspace !== undefined) attachWorkspace(created.sessionId)
}
if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
return ok(request, { sessionId: created.sessionId })
},
history: async (request) => {
@@ -538,6 +626,13 @@ export function createFixtureApi(): ApiProxy {
if (summary === undefined) {
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
}
if (options.rejectPrompt) {
return err(request, {
code: 'agent-busy',
message: 'fixture: prompt rejected before acceptance',
details: { reason: 'fixture-prompt-rejection' },
})
}
summary.updatedAt = Date.now()
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
const durable: ContentBlock[] = content.map((block) => {
@@ -641,9 +736,30 @@ export function createFixtureApi(): ApiProxy {
maxImagePixels: 40_000_000,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
},
attachedSessions: 1,
attachedSessions,
}),
},
workspace: {
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
create: (request) => {
const { path, name } = request.payload
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
const existing = workspaces.find(w => w.path === target)
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,
sessionIds: [],
createdAt: now,
updatedAt: now,
}
workspaces.unshift(created)
emitHost({ type: 'host/workspace-changed', workspace: { ...created } })
return ok(request, { workspace: { ...created }, created: true })
},
},
events: {
async *mux(_request, signal) {
const conn = new FxInbox<MuxFrame>()
@@ -724,7 +840,12 @@ export function createFixtureApi(): ApiProxy {
* to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
*/
export class FixtureApiClient extends AbstractApiClient {
private readonly api = createFixtureApi()
private readonly api: ApiProxy
constructor() {
super()
this.api = createFixtureApi(fixtureOptionsFromLocation())
}
protected doFetch(): Promise<Response> {
throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable')
@@ -753,6 +874,8 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.attachment': return this.api.sessions.attachment(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
}
}
@@ -797,3 +920,16 @@ export class FixtureApiClient extends AbstractApiClient {
return this.api.respond(message)
}
}
/** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */
function fixtureOptionsFromLocation(): FixtureOptions {
if (typeof location === 'undefined') return {}
const query = new URLSearchParams(location.search)
return {
empty: query.get('fixture') === 'empty',
rejectPrompt: query.get('fixturePrompt') === 'reject',
failWorkspaceAttach: query.get('fixtureAttach') === 'fail',
dropSessionCreateResponse: query.get('fixtureSessionCreate') === 'drop-response',
createFrameOrder: query.get('fixtureFrames') === 'workspace-first' ? 'workspace-first' : 'session-first',
}
}

View File

@@ -1,10 +1,7 @@
/**
* Browser half of the wire consumer layer (contract: api-contracts v3
* section 3; export inventory = v3 §3.2). The wire is this package's client
* half in its entirety — apply mounts ctx.connection: the shared api client
* plus the connection controller handle. Mode selection (?fixture) happens
* here so the rest of the client tree is mode-blind; the controller's sinks
* are wired by the runtime plugin (object layer), which injects this service.
* Browser wire client. The plugin selects fixture or HTTP transport, provides
* the shared API client, and lets the runtime object layer start the stream
* controller with its sinks.
*/
import type { Context } from 'cordis'
import type { IApiClient } from './api.ts'
@@ -16,16 +13,15 @@ import { WebApiClient } from './web-api-client.ts'
export type {
ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ToolCallView, ToolResultView,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'
// ---- Connection loop types (part of the ConnectionHandle.start contract;
// the controller class itself stays package-internal — apply owns the loop,
// tests reach it via src) ----
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState }

View File

@@ -0,0 +1,83 @@
/**
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
* web carrier; the fetch-shaped handler itself is transport-agnostic).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
/**
* Bridge one node:http request to the fetch-shaped handler (client close
* aborts; SSE bodies stream out chunk by chunk).
* @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: { fetch: typeof fetch },
maxRequestBodyBytes: number,
): Promise<void> {
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
// fully consumed (immediately for a bodyless GET), which would abort every SSE
// stream right after open. ServerResponse 'close' fires on connection teardown;
// writableEnded distinguishes a normal end() from the client going away.
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
const declaredLength = req.headers['content-length']
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
res.writeHead(413)
res.end()
req.resume()
return
}
const chunks: 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'), {
method: req.method ?? 'GET',
headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]),
...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {},
signal: abort.signal,
})
const response = await apiHandler.fetch(request)
res.writeHead(response.status, Object.fromEntries(response.headers.entries()))
if (response.body === null) {
res.end()
return
}
for await (const chunk of response.body) {
// Backpressure: a false return means the socket buffer is full — wait for drain
// instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
// resolves so a mid-wait disconnect can't park this loop forever; the close
// handler above aborts the handler stream, which then ends the iteration.
if (!res.write(chunk)) {
await new Promise<void>((resolve) => {
const done = (): void => {
res.off('drain', done)
res.off('close', done)
resolve()
}
res.once('drain', done)
res.once('close', done)
})
}
}
res.end()
}

View File

@@ -1,10 +1,36 @@
/**
* Connection plugin, node half. The package IS a dshClient plugin: the wire
* consumer layer lives in its client half in full (src/client/ — contract:
* api-contracts v3 section 3, inventory §3.2); consumers import the /client
* subpath. The empty apply exists so the plugin appears in the host Loader
* (lifecycle governance + dshClient discovery).
*/
/** Host HTTP bridge for browser-client RPC. */
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-attachment'
// Activates the httpServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
/** Host plugin body — no host-side behavior for the connection plugin. */
export function apply(_ctx: unknown): void {}
export { API_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */
export const name = 'client-connection'
/** Headroom for RPC JSON fields around the aggregate base64 image payload. */
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
/** Services required before mounting the route. */
export const inject = ['httpServer', 'apiProxy', 'attachments']
/**
* Mounts the API gateway under the browser transport prefix.
* @param ctx - Host plugin context.
*/
export function apply(ctx: Context): void {
const apiHandler = toFetchHandler(ctx.apiProxy)
const maxRequestBodyBytes = Math.ceil(
ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3,
) + REQUEST_ENVELOPE_HEADROOM_BYTES
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: (req, res) => bridge(req, res, apiHandler, maxRequestBodyBytes),
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
}

View File

@@ -15,10 +15,11 @@ export const name = 'client-connection-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the pure wire layer emits no cordis events and owns no
* No runtime invariant: the wire layer emits no cordis events and owns no
* mutable cross-plugin relation — stream/reconnect sequencing is exercised
* directly by its behavior specs, and rpcId round-trip discipline is owned by
* the apiproxy contract layer.
* directly by its behavior specs, rpcId round-trip discipline is owned by the
* apiproxy contract layer, and the node half's single route registration's
* register/dispose symmetry is audited by the webserver package's invariant.
*/
const install: InvariantInstaller = () => {}