feat(gui): add ask-user question composer

This commit is contained in:
Yichen Jiang
2026-07-22 23:39:50 +08:00
parent b51d2b3d67
commit 03889cee1a
58 changed files with 1905 additions and 115 deletions

View File

@@ -33,6 +33,7 @@ export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),

View File

@@ -30,6 +30,7 @@ export function RpcId(id: string): RpcId {
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'agent-busy': { reason: string }
'internal': {}

View File

@@ -29,6 +29,7 @@ describe('RpcId', () => {
describe('rpcErrorSchema', () => {
it('accepts every code branch with its required details', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-host-runtime
Host runtime assembly for `dsc`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, 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, system prompt, tools, agents, agent loop, 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.
@@ -14,7 +14,7 @@ Which plugins mount and with what defaults is decided only here — shells must
## 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
@@ -26,6 +26,6 @@ No direct invalidation; the mounted model-facing plugins own their request-prefi
## 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.
- **`session.list` covers live sessions only** — cold sessions in the persistence directory are not yet merged into the listing; `host.describe.version` is a placeholder rather than the `apps/cli` package version.
- **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 read 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

@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
@@ -69,6 +70,7 @@
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
},
"peerDependencies": {

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'
@@ -12,9 +10,16 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, 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
@@ -155,6 +160,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?: unknown }
/** 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
@@ -219,12 +253,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
@@ -361,9 +453,19 @@ 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 }))
}
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
// opened mid-turn) backscans the session's in-memory events instead.
@@ -393,7 +495,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
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) {
@@ -421,9 +526,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

@@ -37,6 +37,7 @@ 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'
/** Options for bootHost — the assembly-layer composition knobs. */
export interface BootHostOptions {
@@ -91,6 +92,7 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
await ctx.plugin(SessionStore)
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

@@ -1,16 +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 —
* entry tree listing the nine 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.
* their package.json dshClient declarations; feature packages may also mount
* their interface-specific host half through the same lifecycle.
*/
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). */
/** The nine 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',
@@ -19,6 +19,7 @@ export const WEB_UI_PLUGINS = [
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-question',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const
@@ -41,7 +42,7 @@ export interface MountedWebPlugins {
export async function mountWebPlugins(ctx: Context): 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.
// depends on all nine UI plugins, so its own URL is the right anchor.
ctx.baseUrl ??= import.meta.url
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))

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

@@ -17,6 +17,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'
@@ -38,6 +39,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

@@ -358,10 +358,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,5 +1,5 @@
/**
* Web UI plugin assembly: the in-memory Loader tree mounts all eight UI
* Web UI plugin assembly: the in-memory Loader tree mounts all nine 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.
*
@@ -10,6 +10,9 @@
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { Context } from 'cordis'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
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'
@@ -31,8 +34,16 @@ afterEach(async () => {
})
describe.skipIf(!built)('mountWebPlugins + registry', () => {
it('mounts the eight-package in-memory Loader tree and projects the boot manifest', async () => {
async function rootWithHostServices(): Promise<Context> {
root = new Context()
await root.plugin(SystemPrompt)
await root.plugin(ToolRegistry)
await root.plugin(UserInteractionService)
return root
}
it('mounts the nine-package in-memory Loader tree and projects the boot manifest', async () => {
root = await rootWithHostServices()
const mounted = await mountWebPlugins(root)
const registry = createHostWebPluginRegistry({
ctx: root,
@@ -59,7 +70,7 @@ describe.skipIf(!built)('mountWebPlugins + registry', () => {
})
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
root = new Context()
root = await rootWithHostServices()
await mountWebPlugins(root)
const second = await mountWebPlugins(root)
// ctx.loader hands out a fresh traced proxy per access, so loader identity

View File

@@ -1,5 +1,5 @@
/**
* mountWebPlugins unit coverage (keyless; the real eight-package walk is the
* mountWebPlugins unit coverage (keyless; the real nine-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
@@ -85,7 +85,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
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
// Environment-dependent outcome: with built lib/ the nine 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
@@ -100,7 +100,7 @@ describe('mountWebPlugins (stubbed loader)', () => {
}
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
expect(root.get('loader') !== undefined).toBe(true)
}, 30_000) // built-env run imports eight real plugin packages through the Loader
}, 30_000) // built-env run imports nine real plugin packages through the Loader
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
const entriesList: FakeEntry[] = []

View File

@@ -137,6 +137,9 @@
{
"path": "../../client/ui-conversation"
},
{
"path": "../../client/ui-question"
},
{
"path": "../../client/ui-trajectory"
}