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

# Conflicts:
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/package.json
#	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/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/host/runtime/src/api-proxy.ts
#	packages/host/runtime/src/boot.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-24 16:29:03 +08:00
503 changed files with 19038 additions and 3597 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-host-runtime
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, workspace instructions, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
@@ -12,21 +12,24 @@ Which plugins mount and with what defaults is decided only here — shells must
| `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. |
| `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
| `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. |
| `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. |
| `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback and accepted-title limits. |
| `sessionTitleLlm` | disabled | `true` enables the 5-word / 10-CJK-character, 4,096-input-byte, 64-output-token, 60-second first-message policy; an explicit config overrides it. An omitted route inherits the logged main-request provider and model. |
## ApiProxy implementation notes
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session on open; the host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
## Model Experience
Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents. When `workspaceContext` is enabled, each agent-loop instance freezes the applicable workspace instructions into its logged request prefix; the owning package documents the exact [model-visible framing](../../context/workspace-context/README.md#prompt-shape).
Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled.
#### KV Cache effect
No direct invalidation; the mounted model-facing plugins own their request-prefix changes.
No main-request invalidation; when enabled, the auxiliary title request has its own cache behavior and leaves the conversation prefix unchanged.
## Known Limitations and Deferred Work
- **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step.
- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence.
- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version.
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.

View File

@@ -33,14 +33,6 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
@@ -51,6 +43,8 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
@@ -71,8 +65,9 @@
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^"
},
"peerDependencies": {
"cordis": "^4.0.0-rc.7",

View File

@@ -1,8 +1,6 @@
/**
* Host-side ApiProxy implementation (minimal-first —
* describe/list/create/history/prompt/cancel and both streams are real,
* respond is a stub). Signature discipline: unary takes the narrow
* RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
* Host-side ApiProxy implementation. Signature discipline: unary takes the
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
*/
import { randomUUID } from 'node:crypto'
@@ -14,9 +12,17 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, PromptContentPart, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionSummary, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -29,7 +35,9 @@ function decodeBase64(data: string): Uint8Array {
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
}
const decoded = Buffer.from(data, 'base64')
if (decoded.toString('base64') !== data) throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
if (decoded.toString('base64') !== data) {
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
}
return new Uint8Array(decoded)
}
@@ -169,6 +177,28 @@ function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
/** Project the latest durable title without exposing title-generation policy. */
function titleFrame(session: Session): SessionTitleFrame | undefined {
const title = foldSessionTitle(session.events)
if (title === undefined) return undefined
return {
type: 'session/title',
sessionId: session.id,
title: title.title,
eventSeq: title.eventSeq,
updatedAt: title.updatedAt,
}
}
/** Queue the subscription baseline followed by its optional title snapshot. */
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
const title = titleFrame(session)
if (title !== undefined) queue.push(frame(title))
}
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
return {
@@ -220,6 +250,35 @@ interface ToolCallData { callId: string; name: string; arguments: string }
/** The tool/result payload fields the presenter path reads. */
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
/** One host-owned question wait, addressed by the stable server-request id. */
interface PendingQuestion {
rpcId: RpcId
sessionId: SessionId
questions: AskUserQuestionItem[]
resolve: (answer: AskUserQuestionAnswer) => void
reject: (error: UserInteractionError) => void
signal?: AbortSignal
onAbort?: () => void
}
/** Validate one answer batch against the exact question request it resolves. */
function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
if (payload.sessionId !== pending.sessionId) return false
const answers = payload.answer.answers
if (answers.length !== pending.questions.length) return false
return answers.every((answer, index) => {
const question = pending.questions[index] as AskUserQuestionItem
if (answer.id !== question.id) return false
if (new Set(answer.selected).size !== answer.selected.length) return false
const custom = answer.custom?.trim()
if (custom !== undefined && custom === '') return false
if (custom !== undefined && answer.selected.length > 0) return false
if (question.multiSelect !== true && answer.selected.length > 1) return false
const labels = new Set(question.options?.map(option => option.label) ?? [])
return answer.selected.every(label => labels.has(label))
})
}
/**
* Compute the render intent for a tool/call or tool/result event through the
* presenters registered at this moment; every other event type gets none. A
@@ -284,12 +343,70 @@ class SessionNotFound extends Error {}
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
* @param defaults - host-level default provider/model: injected as
* agentOptions on create/resume, reported by describe from the same source.
* @returns the ApiProxy implementation (minimal-first; stubs noted per method).
* @returns the ApiProxy implementation.
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
const envelope = frame(payload)
for (const queue of muxQueues) queue.push(envelope)
}
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
pendingQuestions.delete(pending.rpcId)
if (pending.signal !== undefined && pending.onAbort !== undefined) {
pending.signal.removeEventListener('abort', pending.onAbort)
}
broadcast({
type: 'question/resolved', sessionId: pending.sessionId,
questionRpcId: pending.rpcId, outcome,
})
}
const disposeProvider = ctx.userInteraction.registerProvider({
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
const sessionId = request.agent?.id
if (sessionId === undefined) {
return Promise.reject(new UserInteractionError(
'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
}
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
const rpcId = RpcId(randomUUID())
const pending: PendingQuestion = {
rpcId, sessionId, questions: request.questions, resolve, reject,
...(request.signal === undefined ? {} : { signal: request.signal }),
}
const onAbort = (): void => {
claimQuestion(pending, 'cancelled')
reject(new UserInteractionError(
'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
}
pending.onAbort = onAbort
pendingQuestions.set(rpcId, pending)
request.signal?.addEventListener('abort', onAbort, { once: true })
const envelope: RpcRequest<MuxFrame> = {
rpcId,
payload: { type: 'question/requested', sessionId, questions: request.questions },
}
for (const queue of muxQueues) queue.push(envelope)
})
},
})
ctx.effect(() => () => {
disposeProvider()
for (const pending of [...pendingQuestions.values()]) {
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
'web user-interaction provider was disposed', 'ASK_ABORTED'))
}
}, 'api-proxy: user-interaction provider')
/**
* Gate the cold path on the store: an id absent from it, or naming a legacy
@@ -404,7 +521,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
else agent.send(durable, { source })
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, { code: 'attachment-error', message: error.message, details: { reason: error.code } })
return err(request, {
code: 'attachment-error',
message: error.message,
details: { reason: error.code },
})
}
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
@@ -426,12 +547,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
try {
const stored = await ctx.attachments.readImage(ref)
return ok(request, { attachment: stored.ref, data: Buffer.from(stored.data).toString('base64') })
return ok(request, {
attachment: stored.ref,
data: Buffer.from(stored.data).toString('base64'),
})
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, { code: 'attachment-error', message: error.message, details: { reason: error.code } })
return err(request, {
code: 'attachment-error',
message: error.message,
details: { reason: error.code },
})
}
return err(request, { code: 'internal', message: 'Unable to read image attachment.', details: {} })
return err(request, {
code: 'internal',
message: 'Unable to read image attachment.',
details: {},
})
}
},
@@ -452,7 +584,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
host: {
async describe(request) {
const activeModel = (await ctx.llm.listModels(defaults.provider)).find(model => model.id === defaults.model)
const activeModel = (await ctx.llm.listModels(defaults.provider))
.find(model => model.id === defaults.model)
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
return ok(request, {
version: '0.0.1',
@@ -472,8 +605,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
events: {
mux(_request, signal) {
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
muxQueues.add(queue)
for (const session of ctx.sessions.list()) {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
subscribeSession(queue, session)
}
for (const pending of pendingQuestions.values()) {
queue.push({
rpcId: pending.rpcId,
payload: {
type: 'question/requested', sessionId: pending.sessionId,
questions: pending.questions,
},
})
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
@@ -496,15 +639,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
if (event.type === 'session/title') {
// The accepted raw event is already in session.events, so the fold must find it.
queue.push(frame(titleFrame(session) as SessionTitleFrame))
}
}),
ctx.on('session/created', (session: Session) => {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
subscribeSession(queue, session)
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)
}),
]
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
return queue.iterate(signal, () => {
muxQueues.delete(queue)
for (const dispose of disposers) dispose()
})
},
host(_request, signal) {
@@ -532,9 +682,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
// TODO(step2): approval/question pending registry (wire answerer + proxy provider).
respond(_message: ClientResponse): Promise<RpcReceipt> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
respond(message: ClientResponse): Promise<RpcReceipt> {
const pending = pendingQuestions.get(message.rpcId)
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) {
if (message.result.error.code !== 'cancelled') {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
'the user cancelled ask_user_question', 'ASK_CANCELLED'))
return Promise.resolve({ accepted: true })
}
const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
if (!parsed.success) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
const payload: QuestionResponsePayload = {
sessionId: parsed.data.sessionId,
answer: {
answers: parsed.data.answer.answers.map(answer => ({
id: answer.id,
selected: answer.selected,
...(answer.custom === undefined ? {} : { custom: answer.custom }),
})),
},
}
if (!matchesQuestions(payload, pending)) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
claimQuestion(pending, 'answered')
pending.resolve(payload.answer)
return Promise.resolve({ accepted: true })
},
}
}

View File

@@ -9,6 +9,9 @@ import Timer from '@cordisjs/plugin-timer'
import LlmService from '@deepseek-ai/dsh-llm'
import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
import * as SessionTitleFirstMessageLlm from '@deepseek-ai/dsh-session-title-first-message-llm'
import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -41,6 +44,23 @@ import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import SpillLocal from '@deepseek-ai/dsh-spill-local'
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
/** Default deterministic title policy for sessions created through the host. */
const DEFAULT_SESSION_TITLE_CONFIG: SessionTitleConfig = {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
maxTitleBytes: 80,
}
/** Default first-message model-title policy for sessions created through the host. */
const DEFAULT_SESSION_TITLE_LLM_CONFIG: SessionTitleLlmConfig = {
targetWords: 5,
targetCjkCharacters: 10,
maxInputBytes: 4_096,
maxOutputTokens: 64,
timeoutMs: 60_000,
}
/** Options for bootHost — the assembly-layer composition knobs. */
export interface BootHostOptions {
@@ -56,6 +76,10 @@ export interface BootHostOptions {
model?: string
/** Additional pi-ai provider routes available to visual-capable Web sessions. */
piAiProviders?: PiAiProviderProfile[]
/** Deterministic fallback-title limits. */
sessionTitle?: SessionTitleConfig
/** Opt-in first-message model-title policy; `true` selects host defaults and an explicit config overrides them. */
sessionTitleLlm?: true | SessionTitleLlmConfig
/**
* Default project directory for sessions created without an explicit cwd
* (defaults to the host process working directory). A session's cwd is its
@@ -102,8 +126,16 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
...options.dshHome === undefined ? {} : { dshHome: options.dshHome },
})
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, options.sessionTitle ?? DEFAULT_SESSION_TITLE_CONFIG)
if (options.sessionTitleLlm !== undefined) {
await ctx.plugin(
SessionTitleFirstMessageLlm,
options.sessionTitleLlm === true ? DEFAULT_SESSION_TITLE_LLM_CONFIG : options.sessionTitleLlm,
)
}
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TaskService)
await ctx.plugin(AgentLoop, { agents: [] })

View File

@@ -11,4 +11,4 @@ export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
export { startHost } from './start.ts'
export type { StartHostOptions, RunningHost } from './start.ts'
export { mountWebPlugins, WEB_UI_PLUGINS } from './web-plugins.ts'
export { mountWebPlugins } from './web-plugins.ts'

View File

@@ -1,27 +1,16 @@
/**
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
* entry tree listing the eight UI plugin packages (the P-I config-source bar —
* a cordis.yml file form comes later; install/remove currently means editing
* this list and restarting). The web plugin registry discovers the entries by
* their package.json dshClient declarations; node halves are empty applies,
* so mounting them here costs nothing beyond Loader governance.
* Web client plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
* entry tree over the caller-supplied client plugin roster. The roster is a
* composition decision and lives in the composing app (apps/cli); this module
* only owns the mount/settle/fail-loud mechanics. The web plugin registry
* discovers fetch-arrival entries among the mounted packages by their
* package.json dshClient declarations; node halves are empty applies, so
* mounting them here costs nothing beyond Loader governance.
*/
import { createRequire } from 'node:module'
import type { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
/** The eight UI plugin packages served to the browser (order = manifest order). */
export const WEB_UI_PLUGINS = [
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-theme',
'@deepseek-ai/dsh-client-i18n',
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const
/** What the shell hands the web plugin registry (loader view + module resolution seam). */
export interface MountedWebPlugins {
/** Entry enumeration surface of the mounted Loader (registry scan source). */
@@ -31,31 +20,36 @@ export interface MountedWebPlugins {
}
/**
* Mount the Loader (when absent) and create one in-memory entry per UI
* plugin, then wait for the tree to settle. A plugin whose import fails
* leaves its entry fiber-less — surfaced here as a loud throw listing the
* failures (misconfiguration must not silently drop a UI plugin).
* Mount the Loader (when absent) and create one in-memory entry per client
* plugin package, then wait for the tree to settle. A plugin whose import
* fails leaves its entry fiber-less — surfaced here as a loud throw listing
* the failures (misconfiguration must not silently drop a client plugin).
* @param ctx - host root context (bootHost product).
* @param plugins - client plugin package names to mount (the composition layer's roster).
* @param anchor - module URL anchoring bare-specifier resolution (the composing
* app's import.meta.url; the roster packages must be dependencies of that app).
* @returns the loader view and package.json resolver the registry consumes.
*/
export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
export async function mountWebPlugins(
ctx: Context, plugins: readonly string[], anchor: string,
): Promise<MountedWebPlugins> {
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
// import silently fails and every entry stays fiber-less. This package
// depends on all eight UI plugins, so its own URL is the right anchor.
ctx.baseUrl ??= import.meta.url
// import silently fails and every entry stays fiber-less. The composing app
// declares the roster packages as dependencies, so its URL is the right anchor.
ctx.baseUrl ??= anchor
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
for (const name of WEB_UI_PLUGINS) {
for (const name of plugins) {
if (!existing.has(name)) await ctx.loader.create({ name })
}
await ctx.loader.await()
const dead = [...ctx.loader.entries()]
.filter(entry => (WEB_UI_PLUGINS as readonly string[]).includes(entry.options.name))
.filter(entry => plugins.includes(entry.options.name))
.filter(entry => entry.fiber === undefined && !entry.disabled)
if (dead.length > 0) {
throw new Error(`web-plugins: UI plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
throw new Error(`web-plugins: client plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
}
const require = createRequire(import.meta.url)
const require = createRequire(anchor)
return {
loader: ctx.loader,
resolvePkgJson: name => require.resolve(`${name}/package.json`),

View File

@@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -32,6 +33,7 @@ describe('sessions.list cold merge', () => {
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
const logPath = join(root, 'a.log')
writeFileSync(logPath, 'log-bytes')
@@ -76,6 +78,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const listed = await api.sessions.list(request({}))

View File

@@ -18,6 +18,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
@@ -39,6 +40,7 @@ async function harness(): Promise<{ ctx: Context }> {
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
ctx.tools.register(tool('gen', {
presentCall: () => ({ card: 'generic', title: 'gen call' }),

View File

@@ -8,6 +8,8 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, GenerateOptions, LlmModelInfo, ModelModality, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -33,6 +35,10 @@ class ScriptedAdapter extends LlmAdapter {
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if ((options.tools?.length ?? 0) === 0) {
yield * textResponse('Durable append-only session titles')
return
}
this.requests.push(options)
const entry = this.script.shift()
if (!entry) throw new Error('ScriptedAdapter: script exhausted')
@@ -81,6 +87,21 @@ function expectOk<T>(response: RpcResponse<T>): T {
return response.result.value
}
async function nextMux(iterator: AsyncIterator<RpcRequest<MuxFrame>>): Promise<RpcRequest<MuxFrame>> {
const next = await iterator.next()
if (next.done === true) throw new Error('mux ended before the expected frame')
return next.value
}
/** Durably append a title event without mounting title-generation policy. */
function appendTitle(ctx: Context, agent: Agent, title: string) {
return ctx.sessions.appendOutOfBand(agent.session, 'session/title', {
title,
messageSeqs: [1],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
}
let host: RunningHost | undefined
beforeEach(() => {
@@ -93,13 +114,19 @@ afterEach(async () => {
vi.unstubAllEnvs()
})
async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
async function boot(
script: (StreamChunk[] | 'hang')[] = [],
sessionTitle?: SessionTitleConfig,
sessionTitleLlm?: true | SessionTitleLlmConfig,
): Promise<RunningHost> {
host = await startHost({
boot: {
persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')),
workspaceContext: false,
provider: 'scripted',
model: 'test-model',
...(sessionTitle === undefined ? {} : { sessionTitle }),
...(sessionTitleLlm === undefined ? {} : { sessionTitleLlm }),
},
})
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
@@ -203,6 +230,23 @@ describe('bootHost / startHost', () => {
expect(requestText).toContain('Instructions from: AGENTS.md')
expect(requestText).toContain('host-workspace-context-probe')
})
it('keeps model title generation disabled when sessionTitleLlm is omitted', async () => {
const running = await boot([textResponse('pong')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'Explain durable session titles.' }],
})))
await idle
expect((await ctx.sessionTitle.refresh(agent.session))?.source).toEqual({ kind: 'fallback' })
expect(agent.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
})
})
describe('host.describe', () => {
@@ -245,6 +289,94 @@ describe('sessions.create / list', () => {
})
describe('sessions.prompt / cancel', () => {
it.each([
{ name: 'host default', config: true, target: '5 words', maxTokens: 64 },
{
name: 'configured policy',
config: {
targetWords: 3,
targetCjkCharacters: 8,
maxInputBytes: 2_048,
maxOutputTokens: 24,
timeoutMs: 2_000,
},
target: '3 words',
maxTokens: 24,
},
] satisfies {
name: string
config: true | SessionTitleLlmConfig
target: string
maxTokens: number
}[])('replaces the fallback with a model-backed first-message title using the $name', async ({ config, target, maxTokens }) => {
const modelTitle = 'Durable append-only session titles'
const running = await boot([textResponse('pong')], undefined, config)
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'Explain why append-only logs make session titles durable.' }],
})))
await idle
await vi.waitFor(() => {
expect(agent.session.events.filter(event => event.type === 'session/title').map(event => event.data))
.toEqual([
{
title: 'Explain why append-only logs make',
messageSeqs: [1],
source: { kind: 'fallback' },
},
{
title: modelTitle,
messageSeqs: [1],
source: {
kind: 'provider',
provider: 'session-title-first-message-llm',
model: { provider: 'scripted', model: 'test-model' },
},
},
])
})
const titleRequest = agent.session.events.find(event => event.type === 'session/title-llm-request')
expect(titleRequest?.data.system).toContain(target)
expect(titleRequest?.data.maxTokens).toBe(maxTokens)
})
it.each([
{ name: 'host default', config: undefined, expected: 'Show the Web UI durable' },
{
name: 'configured limit',
config: { fallbackMaxWords: 2, fallbackMaxBytes: 40, maxTitleBytes: 80 },
expected: 'Show the',
},
] satisfies { name: string; config: SessionTitleConfig | undefined; expected: string }[])(
'logs a durable fallback title with the $name',
async ({ config, expected }) => {
const running = await boot([textResponse('pong')], config)
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
expectOk(await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'Show the Web UI durable session title' }],
})))
await idle
const title = agent.session.events.find(event => event.type === 'session/title')
expect(title?.data).toEqual({
title: expected,
messageSeqs: [1],
source: { kind: 'fallback' },
})
},
)
it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
const running = await boot([textResponse('pong')])
const { api, ctx } = running
@@ -556,6 +688,7 @@ describe('sessions.history', () => {
const idle = waitForIdle(first.ctx, agent)
agent.send([{ type: 'text', text: 'save me' }])
await idle
const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title')
await first.dispose()
host = await startHost({
@@ -563,6 +696,8 @@ describe('sessions.history', () => {
})
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
expect(host.ctx.agents.get(sessionId)).toBeUndefined()
const abort = new AbortController()
const mux = host.api.events.mux(request({}), abort.signal)[Symbol.asyncIterator]()
const [a, b] = await Promise.all([
host.api.sessions.history(request({ sessionId })),
host.api.sessions.history(request({ sessionId })),
@@ -573,6 +708,11 @@ describe('sessions.history', () => {
}
expect(host.ctx.agents.get(sessionId)).toBeDefined()
expect(host.ctx.agents.list()).toHaveLength(1)
expect((await nextMux(mux)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
expect((await nextMux(mux)).payload).toEqual(expect.objectContaining({
type: 'session/title', sessionId, title: 'Persisted title', eventSeq: titleEvent.seq,
}))
abort.abort()
})
it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
@@ -680,6 +820,43 @@ describe('events streams', () => {
expect((await stream.next()).done).toBe(true)
})
it('mux: projects durable titles after open baselines and immediately after live raw events', async () => {
const running = await boot()
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const initial = await appendTitle(ctx, agent, 'Initial title')
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
type: 'session/title', sessionId, title: 'Initial title', eventSeq: initial.seq, updatedAt: initial.time,
}))
const revised = await appendTitle(ctx, agent, 'Revised title')
let raw: RpcRequest<MuxFrame>
do raw = await nextMux(stream)
while (!(raw.payload.type === 'session/event' && raw.payload.event.type === 'session/title'))
expect(raw.payload).toMatchObject({ type: 'session/event', sessionId, event: { seq: revised.seq } })
expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
type: 'session/title', sessionId, title: 'Revised title', eventSeq: revised.seq, updatedAt: revised.time,
}))
ac.abort()
})
it('mux: emits no title control for untitled subscriptions', async () => {
const { api } = await boot()
const first = expectOk(await api.sessions.create(request({}))).sessionId
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: first })
const second = expectOk(await api.sessions.create(request({}))).sessionId
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: second })
ac.abort()
})
it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
const running = await boot([textResponse('x')])
const { api, ctx } = running
@@ -713,10 +890,175 @@ describe('events streams', () => {
})
})
describe('respond stub', () => {
it('always reports not-pending (step2 registry pending)', async () => {
const { api } = await boot()
const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
describe('question request / response', () => {
const questions = [{
id: 'mode', question: 'Choose a mode',
options: [
{ label: 'Fast (Recommended)', description: 'Move quickly.' },
{ label: 'Careful', description: 'Review first.' },
],
}]
it('waits, replays the same rpcId on reconnect, validates, and resolves first-wins', async () => {
const running = await boot()
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
await stream.next() // subscribed baseline starts the generator and installs the queue
const answerPromise = ctx.userInteraction.ask({ questions, agent })
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
expect(requested.payload).toMatchObject({ type: 'question/requested', sessionId, questions })
const wrongSession = await api.respond({
type: 'client-response', rpcId: requested.rpcId,
result: {
ok: true,
value: { sessionId: 'session-other', answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
},
})
expect(wrongSession).toEqual({ accepted: false, reason: 'bad-response' })
const badChoice = await api.respond({
type: 'client-response', rpcId: requested.rpcId,
result: {
ok: true,
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Unknown'] }] } },
},
})
expect(badChoice).toEqual({ accepted: false, reason: 'bad-response' })
const invalidResults = [
{ ok: true as const, value: null },
{ ok: true as const, value: { sessionId, answer: { answers: [] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'wrong', selected: ['Fast (Recommended)'] }] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Fast (Recommended)'] }] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)', 'Careful'] }] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: [], custom: ' ' }] } } },
{ ok: true as const, value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Careful'], custom: 'Other' }] } } },
{ ok: false as const, error: { code: 'internal' as const, message: 'wrong error', details: {} } },
]
for (const result of invalidResults) {
expect(await api.respond({
type: 'client-response', rpcId: requested.rpcId, result,
})).toEqual({ accepted: false, reason: 'bad-response' })
}
const reconnectAbort = new AbortController()
const replay = api.events.mux(request({}), reconnectAbort.signal)[Symbol.asyncIterator]()
await replay.next()
const replayed = (await replay.next()).value as RpcRequest<MuxFrame>
expect(replayed.rpcId).toBe(requested.rpcId)
expect(replayed.payload).toEqual(requested.payload)
const response = {
type: 'client-response' as const,
rpcId: requested.rpcId,
result: {
ok: true as const,
value: { sessionId, answer: { answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }] } },
},
}
const [first, duplicate] = await Promise.all([api.respond(response), api.respond(response)])
expect([first, duplicate]).toContainEqual({ accepted: true })
expect([first, duplicate]).toContainEqual({ accepted: false, reason: 'not-pending' })
await expect(answerPromise).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Fast (Recommended)'] }],
})
const resolved = (await stream.next()).value as RpcRequest<MuxFrame>
expect(resolved.payload).toMatchObject({
type: 'question/resolved', sessionId, questionRpcId: requested.rpcId, outcome: 'answered',
})
expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' })
const customQuestions = [{ id: 'detail', question: 'What else?' }]
const customAnswer = ctx.userInteraction.ask({ questions: customQuestions, agent })
const customRequested = (await stream.next()).value as RpcRequest<MuxFrame>
expect(await api.respond({
type: 'client-response', rpcId: customRequested.rpcId,
result: {
ok: true,
value: { sessionId, answer: { answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }] } },
},
})).toEqual({ accepted: true })
await expect(customAnswer).resolves.toEqual({
answers: [{ id: 'detail', selected: [], custom: 'Keep traces' }],
})
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
type: 'question/resolved', questionRpcId: customRequested.rpcId, outcome: 'answered',
})
const blankAnswer = ctx.userInteraction.ask({ questions, agent })
const blankRequested = (await stream.next()).value as RpcRequest<MuxFrame>
expect(await api.respond({
type: 'client-response', rpcId: blankRequested.rpcId,
result: {
ok: true,
value: { sessionId, answer: { answers: [{ id: 'mode', selected: [] }] } },
},
})).toEqual({ accepted: true })
await expect(blankAnswer).resolves.toEqual({
answers: [{ id: 'mode', selected: [] }],
})
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
type: 'question/resolved', questionRpcId: blankRequested.rpcId, outcome: 'answered',
})
ac.abort()
reconnectAbort.abort()
})
it('distinguishes user cancellation from owner abort and rejects late responses', async () => {
const running = await boot()
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const streamAbort = new AbortController()
const stream = api.events.mux(request({}), streamAbort.signal)[Symbol.asyncIterator]()
await stream.next()
const cancelled = ctx.userInteraction.ask({ questions, agent }).catch((error: unknown) => error)
const requested = (await stream.next()).value as RpcRequest<MuxFrame>
expect(await api.respond({
type: 'client-response', rpcId: requested.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } },
})).toEqual({ accepted: true })
await expect(cancelled).resolves.toMatchObject({ code: 'ASK_CANCELLED' })
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
type: 'question/resolved', outcome: 'cancelled',
})
const ownerAbort = new AbortController()
const aborted = ctx.userInteraction.ask({ questions, agent, signal: ownerAbort.signal })
.catch((error: unknown) => error)
const abortRequest = (await stream.next()).value as RpcRequest<MuxFrame>
ownerAbort.abort()
await expect(aborted).resolves.toMatchObject({ code: 'ASK_ABORTED' })
expect(((await stream.next()).value as RpcRequest<MuxFrame>).payload).toMatchObject({
type: 'question/resolved', questionRpcId: abortRequest.rpcId, outcome: 'cancelled',
})
expect(await api.respond({
type: 'client-response', rpcId: abortRequest.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'late', details: {} } },
})).toEqual({ accepted: false, reason: 'not-pending' })
streamAbort.abort()
})
it('rejects missing routing and pre-abort, then aborts outstanding waits on disposal', async () => {
const running = await boot()
const { ctx } = running
await expect(ctx.userInteraction.ask({ questions })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' })
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const alreadyAborted = new AbortController()
alreadyAborted.abort()
await expect(ctx.userInteraction.ask({ questions, agent, signal: alreadyAborted.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
const outstanding = ctx.userInteraction.ask({ questions, agent })
const disposed = running.dispose()
host = undefined
await expect(outstanding).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await disposed
})
})

View File

@@ -1,71 +0,0 @@
/**
* Web UI plugin assembly: the in-memory Loader tree mounts all eight UI
* packages (node halves), and the webserver registry built over it yields the
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
*
* The Loader imports plugin packages through their exports maps (lib/), so
* this is a built-artifact e2e: it skips until the workspace build has run
* (`pnpm run build`), like the other built-* e2e suites.
*/
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
const nodeRequire = createRequire(import.meta.url)
const built = WEB_UI_PLUGINS.every((name) => {
try {
return existsSync(nodeRequire.resolve(name))
} catch {
return false
}
})
let root: Context | undefined
afterEach(async () => {
await root?.fiber.dispose()
root = undefined
})
describe.skipIf(!built)('mountWebPlugins + registry', () => {
it('mounts the eight-package in-memory Loader tree and projects the boot manifest', async () => {
root = new Context()
const mounted = await mountWebPlugins(root)
const registry = createHostWebPluginRegistry({
ctx: root,
loader: mounted.loader,
resolvePkgJson: mounted.resolvePkgJson,
onError: (err) => { throw err },
})
const rows = registry.snapshot()
expect(rows.map(r => r.id)).toEqual([...WEB_UI_PLUGINS])
// The infra four are the early-load group; the UI four are not.
const immediate = rows.filter(r => r.immediately === true).map(r => r.id)
expect(immediate).toEqual([
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-theme',
'@deepseek-ai/dsh-client-i18n',
])
// Every row resolves a client path under its own package lib/.
for (const row of rows) {
expect(registry.clientPath(row.id)).toMatch(/lib[/\\]client\.js$/)
expect(row.url).toBe(`/plugins/${row.id}/client.js`)
}
registry.dispose()
})
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
root = new Context()
await mountWebPlugins(root)
const second = await mountWebPlugins(root)
// ctx.loader hands out a fresh traced proxy per access, so loader identity
// is not assertable; the observable contract is a single entry per package.
const names = [...second.loader.entries()].map(e => e.options.name)
.filter(n => (WEB_UI_PLUGINS as readonly string[]).includes(n))
expect(names.length).toBe(WEB_UI_PLUGINS.length)
})
})

View File

@@ -1,13 +1,20 @@
/**
* mountWebPlugins unit coverage (keyless; the real eight-package walk is the
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
* resolver seam — is exercised against a stubbed loader service so it runs
* without built lib/ artifacts.
* mountWebPlugins unit coverage (keyless). The Loader-facing behavior —
* baseUrl anchoring, entry creation with idempotent reuse, the fiber-less
* fail-loud sweep, and the resolver seam — is exercised against a stubbed
* loader service so it runs without built lib/ artifacts. The roster is
* caller-supplied now (composition moved to apps/cli), so these tests pass
* their own lists.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
import { mountWebPlugins } from '../src/web-plugins.ts'
const ROSTER = [
'@deepseek-ai/dsh-plugin-a',
'@deepseek-ai/dsh-plugin-b',
'@deepseek-ai/dsh-plugin-c',
] as const
interface FakeEntry {
options: { name: string }
@@ -47,60 +54,50 @@ function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void)
}
describe('mountWebPlugins (stubbed loader)', () => {
it('creates one entry per UI plugin, awaits the tree, and returns the loader view + resolver', async () => {
it('creates one entry per roster package, awaits the tree, and returns the loader view + resolver', async () => {
const entriesList: FakeEntry[] = []
const { ctx, loader } = withLoader(entriesList, (name) => {
entriesList.push({ options: { name }, fiber: {}, disabled: false })
})
const mounted = await mountWebPlugins(ctx)
expect(loader.created).toEqual([...WEB_UI_PLUGINS])
const mounted = await mountWebPlugins(ctx, ROSTER, import.meta.url)
expect(loader.created).toEqual([...ROSTER])
expect(loader.awaited).toBe(1)
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...WEB_UI_PLUGINS])
// The resolver resolves this package's own manifest through real module resolution.
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...ROSTER])
// The resolver resolves a real package manifest through real module resolution, anchored at this test file.
expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
expect(ctx.baseUrl).toBeDefined()
})
it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
const preexisting: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: {}, disabled: false }))
const preexisting: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: {}, disabled: false }))
const { ctx, loader } = withLoader(preexisting)
await mountWebPlugins(ctx)
await mountWebPlugins(ctx, ROSTER, import.meta.url)
expect(loader.created).toEqual([])
})
it('throws listing every fiber-less entry (silent import failure must not drop a UI plugin)', async () => {
it('throws listing every fiber-less entry (silent import failure must not drop a client plugin)', async () => {
const entriesList: FakeEntry[] = []
const { ctx } = withLoader(entriesList, (name) => {
// First two load; the rest stay fiber-less (import failed silently).
entriesList.push({ options: { name }, fiber: entriesList.length < 2 ? {} : undefined, disabled: false })
// First one loads; the rest stay fiber-less (import failed silently).
entriesList.push({ options: { name }, fiber: entriesList.length < 1 ? {} : undefined, disabled: false })
})
await expect(mountWebPlugins(ctx)).rejects.toThrow(/UI plugin\(s\) failed to load: .*dsh-client-ui-theme/)
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url))
.rejects.toThrow(/client plugin\(s\) failed to load: .*dsh-plugin-c/)
})
it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
const entriesList: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
const entriesList: FakeEntry[] = ROSTER.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
const { ctx } = withLoader(entriesList)
await expect(mountWebPlugins(ctx)).resolves.toBeDefined()
await expect(mountWebPlugins(ctx, ROSTER, import.meta.url)).resolves.toBeDefined()
})
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
root = new Context()
// Environment-dependent outcome: with built lib/ the eight imports load
// and the mount resolves; without them every entry stays fiber-less and
// the sweep throws its loud list. Either way the branch under test is the
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
// expect()'s formatting path (pretty-format probes throw on them).
// Plain string: the success sentinel and error text share one channel.
let outcome: string
try {
await mountWebPlugins(root)
outcome = 'resolved'
} catch (error) {
outcome = error instanceof Error ? error.message : String(error)
}
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
// An empty roster keeps this keyless and artifact-free: the branch under
// test is only the Loader auto-mount.
await mountWebPlugins(root, [], import.meta.url)
expect(root.get('loader') !== undefined).toBe(true)
}, 30_000) // built-env run imports eight real plugin packages through the Loader
}, 30_000) // cold-cache import of the real vendored Loader crosses the network-disk 5s default
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
const entriesList: FakeEntry[] = []
@@ -108,7 +105,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
entriesList.push({ options: { name }, fiber: {}, disabled: false })
})
ctx.baseUrl = 'file:///caller/anchor/'
await mountWebPlugins(ctx)
await mountWebPlugins(ctx, ROSTER, import.meta.url)
expect(ctx.baseUrl).toBe('file:///caller/anchor/')
})
})

View File

@@ -29,6 +29,12 @@
{
"path": "../../core/session"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../session-title/session-title-first-message-llm"
},
{
"path": "../../core/system-prompt"
},
@@ -68,9 +74,6 @@
{
"path": "../../fs/tool-fs-search"
},
{
"path": "../../context/workspace-context"
},
{
"path": "../../llm/token-meter"
},
@@ -126,28 +129,10 @@
"path": "../../../vendor/loader"
},
{
"path": "../../client/connection"
"path": "../../context/workspace-context"
},
{
"path": "../../client/runtime"
},
{
"path": "../../client/ui-theme"
},
{
"path": "../../client/i18n"
},
{
"path": "../../client/ui-layout"
},
{
"path": "../../client/ui-sidebar"
},
{
"path": "../../client/ui-conversation"
},
{
"path": "../../client/ui-trajectory"
"path": "../../ui/user-interaction"
}
]
}