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

@@ -2,6 +2,10 @@
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation publishes its validated `host.describe` value through `onDescription` before `onConnected`; a business-error response fails the generation like a transport error. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
## Model Experience
None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.

View File

@@ -44,10 +44,12 @@
"src"
],
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

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 = () => {}

View File

@@ -74,6 +74,14 @@ export class FakeApiClient implements IApiClient {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
}
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))),
create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
created: true,
}))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -5,7 +5,7 @@
* the hand-written fixture/host parallel implementations.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import type { SessionId, WorkspaceId } from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts'
import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
@@ -87,7 +87,7 @@ describe('createFixtureApi', () => {
await consuming
if (!created.result.ok) throw new Error('create failed')
const createdId = created.result.value.sessionId
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }])
expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, cwd: '/tmp/fixture' }])
const list = await api.sessions.list(req({}))
if (!list.result.ok) throw new Error('list failed')
expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
@@ -306,6 +306,213 @@ describe('createFixtureApi', () => {
const api = createFixtureApi()
const response = await api.host.describe(req({}))
expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
const empty = await createFixtureApi({ empty: true }).host.describe(req({}))
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
})
it('workspace.list serves the resident account and create reuses on path collision', async () => {
const api = createFixtureApi()
const listed = await api.workspace.list(req({}))
if (!listed.result.ok) throw new Error('list failed')
expect(listed.result.value.items).toEqual([expect.objectContaining({
workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
})])
// path collision → the existing entity comes back, created:false, no frame.
const reused = await api.workspace.create(req({ path: '/tmp/fixture' }))
if (!reused.result.ok) throw new Error('reuse failed')
expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } })
})
it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const created = await api.workspace.create(req({ name: 'nova' }))
if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.created).toBe(true)
expect(created.result.value.workspace).toMatchObject({
path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [],
})
await consuming
expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }])
// path spelling falls back to the basename when no title/name rides along.
const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' }))
if (!pathOnly.result.ok) throw new Error('pathOnly failed')
expect(pathOnly.result.value.workspace.title).toBe('base')
// Degenerate spellings reach the impl unfiltered (the fixture carrier has
// no schema gate): both-absent falls back to the bucket dir, and a
// basename-less path serves as its own title.
const bare = await api.workspace.create(req({}))
if (!bare.result.ok) throw new Error('bare failed')
expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' })
const rootPath = await api.workspace.create(req({ path: '/' }))
if (!rootPath.result.ok) throw new Error('rootPath failed')
expect(rootPath.result.value.workspace.title).toBe('/')
})
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
if (seen.length >= 2) abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
const created = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId }))
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId
await consuming
// The session lands with the workspace's path as cwd, and the account
// write pushes the fresh workspace snapshot after session-added.
expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, cwd: '/tmp/fixture' })
expect(seen[1]).toMatchObject({
type: 'host/workspace-changed',
workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] },
})
})
it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => {
const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' })
const initialSessions = await api.sessions.list(req({}))
const initialWorkspaces = await api.workspace.list(req({}))
expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } })
expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } })
const made = await api.workspace.create(req({ name: 'nova' }))
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2)
await new Promise(resolve => setTimeout(resolve, 10))
const preallocated = sid('fx-preallocated')
const created = await api.sessions.create(req({
workspaceId: made.result.value.workspace.workspaceId,
sessionId: preallocated,
}))
expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } })
const frames = await framesPromise
expect(frames[0]).toMatchObject({
type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] },
})
expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, cwd: made.result.value.workspace.path })
const retried = await api.sessions.create(req({
workspaceId: made.result.value.workspace.workspaceId,
sessionId: preallocated,
}))
expect(retried.result).toEqual({ ok: true, value: { sessionId: preallocated } })
const listed = await api.sessions.list(req({}))
if (!listed.result.ok) throw new Error('session list failed')
expect(listed.result.value.items.filter(item => item.sessionId === preallocated)).toHaveLength(1)
const conflict = await api.sessions.create(req({ sessionId: preallocated, cwd: '/elsewhere' }))
expect(conflict.result).toMatchObject({
ok: false,
error: { code: 'session-conflict', details: { sessionId: preallocated, requestedCwd: '/elsewhere' } },
})
})
it('attaches an existing ungrouped Session to a matching Workspace', async () => {
const api = createFixtureApi()
const sessionId = sid('fx-existing-ungrouped')
await expect(api.sessions.create(req({ sessionId, cwd: '/tmp/fixture' }))).resolves.toMatchObject({
result: { ok: true, value: { sessionId } },
})
await expect(api.sessions.create(req({
sessionId,
workspaceId: 'fx-ws-fixture' as WorkspaceId,
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
const workspaces = await api.workspace.list(req({}))
if (!workspaces.result.ok) throw new Error('workspace list failed')
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
})
it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => {
const api = createFixtureApi()
const listed = await api.sessions.list(req({}))
if (!listed.result.ok) throw new Error('session list failed')
const existing = listed.result.value.items.find(item => item.sessionId === sid('fx-alpha'))
if (existing === undefined) throw new Error('fixture Session missing')
delete existing.cwd
const conflict = await api.sessions.create(req({ sessionId: existing.sessionId }))
expect(conflict.result).toEqual({
ok: false,
error: {
code: 'session-conflict',
message: `session ${existing.sessionId} already uses no cwd`,
details: { sessionId: existing.sessionId, requestedCwd: '/tmp/fixture' },
},
})
})
it('publishes an ungrouped Session when Workspace attachment fails', async () => {
const api = createFixtureApi({ failWorkspaceAttach: true })
const sessionId = sid('fx-partial')
const created = await api.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
}))
expect(created.result).toMatchObject({
ok: false,
error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } },
})
const listed = await api.sessions.list(req({}))
const workspaces = await api.workspace.list(req({}))
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId)
const retried = await api.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
}))
expect(retried.result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
const afterRetry = await api.sessions.list(req({}))
if (!afterRetry.result.ok) throw new Error('list failed')
expect(afterRetry.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1)
})
it('reconciles a dropped create response and can reject a prompt before acceptance', async () => {
const sessionId = sid('fx-lost-response')
const dropped = createFixtureApi({ dropSessionCreateResponse: true })
await expect(Promise.resolve().then(() => dropped.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
})))).rejects.toThrow(/dropped session\.create response/)
const listed = await dropped.sessions.list(req({}))
const workspaces = await dropped.workspace.list(req({}))
if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed')
expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true)
expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId)
await expect(dropped.sessions.create(req({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId,
}))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } })
const rejecting = createFixtureApi({ empty: true, rejectPrompt: true })
const real = await rejecting.sessions.create(req({ sessionId: sid('fx-rejected') }))
if (!real.result.ok) throw new Error('session create failed')
const prompt = await rejecting.sessions.prompt(req({
sessionId: real.result.value.sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'keep me' }],
}))
expect(prompt.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
})
it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
@@ -358,6 +565,7 @@ describe('createFixtureApi', () => {
describe('FixtureApiClient (protocol-level fake carrier)', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
@@ -394,6 +602,57 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
expect((await client.sessions.attachment({ sessionId: sid('fx-alpha'), attachmentId: 'fixture:image' as never })).result.ok).toBe(true)
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
expect((await client.workspace.list({})).result.ok).toBe(true)
const workspace = await client.workspace.create({ name: 'via-client' })
if (!workspace.result.ok) throw new Error('workspace create failed')
expect(workspace.result.value.workspace.title).toBe('via-client')
})
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
vi.stubGlobal('location', {
search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first',
})
const client = new FixtureApiClient()
await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
const made = await client.workspace.create({ name: 'query-workspace' })
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2)
await new Promise(resolve => setTimeout(resolve, 10))
const sessionId = sid('fx-query-session')
const created = await client.sessions.create({
workspaceId: made.result.value.workspace.workspaceId,
sessionId,
})
expect(created.result).toMatchObject({ ok: true, value: { sessionId } })
const frames = await framesPromise
expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added'])
const rejected = await client.sessions.prompt({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'retain' }],
})
expect(rejected.result).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
})
it('maps attach-failure and dropped-response query scenarios', async () => {
vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' })
const partial = new FixtureApiClient()
const partialResult = await partial.sessions.create({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId: sid('fx-query-partial'),
})
expect(partialResult.result).toMatchObject({
ok: false,
error: { code: 'workspace-attach-failed', details: { sessionId: 'fx-query-partial' } },
})
vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' })
const dropped = new FixtureApiClient()
await expect(dropped.sessions.create({
workspaceId: 'fx-ws-fixture' as WorkspaceId,
sessionId: sid('fx-query-dropped'),
})).rejects.toThrow(/dropped session\.create response/)
})
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {

View File

@@ -1,10 +1,37 @@
/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { apply } from '../src/index.ts'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import { API_PATH, apply, inject } from '../src/index.ts'
describe('node half', () => {
it('apply is a no-op host placeholder', () => {
apply(undefined)
expect(true).toBe(true) // reaching here without throw is the contract
describe('connection node half', () => {
it('registers the /api prefix route and removes it with the fiber', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
ctx.provide('httpServer', httpServer as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
ctx.provide('attachments', {
imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
} as AttachmentStore)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
await fiber.dispose()
expect(routes).toHaveLength(0)
})
})

View File

@@ -2,7 +2,8 @@
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
"outDir": "lib/types",
"types": ["node"]
},
"include": [
"src"
@@ -23,6 +24,9 @@
{
"path": "../../host/apiproxy"
},
{
"path": "../../host/webserver"
},
{
"path": "../../ui/user-approval"
},