Merge remote-tracking branch 'origin/master' into worktree/acp-automation-protocol
This commit is contained in:
@@ -32,15 +32,6 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "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-question": "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:^",
|
||||
@@ -73,8 +64,8 @@
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
/**
|
||||
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
||||
* 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; feature packages may also mount
|
||||
* their interface-specific host half through the same lifecycle.
|
||||
* 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 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',
|
||||
'@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-question',
|
||||
'@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). */
|
||||
@@ -32,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 nine 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`),
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 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'
|
||||
|
||||
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', () => {
|
||||
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,
|
||||
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 = await rootWithHostServices()
|
||||
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 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
|
||||
* 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 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
|
||||
// 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 nine 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/')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,9 +68,6 @@
|
||||
{
|
||||
"path": "../../fs/tool-fs-search"
|
||||
},
|
||||
{
|
||||
"path": "../../context/workspace-context"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
@@ -126,31 +123,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-question"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-trajectory"
|
||||
"path": "../../ui/user-interaction"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user