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:
@@ -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({}))
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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/')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user