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

# Conflicts:
#	apps/cli/README.md
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/src/client/stores.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/runtime/package.json
#	packages/host/runtime/src/boot.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/host/runtime/tsconfig.json
#	packages/host/webserver/README.md
#	packages/host/webserver/src/index.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-25 22:35:22 +08:00
1264 changed files with 37694 additions and 29428 deletions

View File

@@ -10,7 +10,7 @@ The TUI surface:
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. `dsh web --provider <name> --model <id>` selects the default agent route; a non-`deepseek` provider additionally mounts that pi-ai catalog route with the provider's ambient credentials (e.g. `ANTHROPIC_API_KEY`), which is how image input reaches a visual-capable model, and requires an explicit `--model`. The headless surface retains deterministic fallback titles without making the auxiliary title-model request.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider <name> --model <id>` mounts that pi-ai catalog route with provider-native ambient credentials; the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
## Install (developer machine)
@@ -20,4 +20,4 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
`pnpm run demo:tui` runs the same entry from the repo root. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.
`pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.

246
apps/cli/cordis.yml Normal file
View File

@@ -0,0 +1,246 @@
# dsh web — the full web-shape composition: host runtime (layer 1), the
# transport/service layer (layer 2), and the browser plugin roster (dshClient
# rows the modules node half scans into window.__DSH_BOOT__). Row order
# carries no load semantics (activation is service-availability driven); the
# grouping below is for readers. `--dev` appends the dsh-client-hmr row in
# code (AppCLIEntry) — prod and dev differ by exactly that one row.
# AppCLIEntry patches this tree before boot: profile json + CLI flags +
# distIndex land as config patches over the rows below (yaml = engineering
# defaults, json = user config, user wins per field).
# ── layer 1: runtime ────────────────────────────────────────────────────────
- id: timer
name: '@cordisjs/plugin-timer'
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'
- id: session
name: '@deepseek-ai/dsh-session'
- id: session-title
name: '@deepseek-ai/dsh-session-title'
config:
fallbackMaxWords: 5
fallbackMaxBytes: 40
maxTitleBytes: 80
# Model-made titles on the first-message cadence (the web sidebar renders
# session/title). Same values as the TUI composition.
- id: session-title-llm
name: '@deepseek-ai/dsh-session-title-first-message-llm'
config:
targetWords: 5
targetCjkCharacters: 10
maxInputBytes: 4096
maxOutputTokens: 64
timeoutMs: 60000
- id: system-prompt
name: '@deepseek-ai/dsh-system-prompt'
config:
persona: ''
- id: tools
name: '@deepseek-ai/dsh-tools'
- id: user-interaction
name: '@deepseek-ai/dsh-user-interaction'
- id: agent
name: '@deepseek-ai/dsh-agent'
- id: tasks
name: '@deepseek-ai/dsh-tasks'
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents: []
# The native DeepSeek adapter; reads the key/base-url the boot's layered
# .env loading (cwd then $DSH_HOME) left in the environment.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: './.sessions'
- id: storage
name: '@deepseek-ai/dsh-storage'
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: './.storages'
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
config:
backend: json
- id: workspace
name: '@deepseek-ai/dsh-workspace'
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# fs cwd stays the package default (process.cwd()) — the same value the
# gateway injects into session.cwd, so paths and sessions agree.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
- id: workspace-context
name: '@deepseek-ai/dsh-workspace-context'
config:
maxBytes: 65536
- id: skill
name: '@deepseek-ai/dsh-skill'
- id: skill-local
name: '@deepseek-ai/dsh-skill-local'
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
# token-meter rejects unknown config keys — keep this row bare.
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subagent-spawn
name: '@deepseek-ai/dsh-subagent-spawn'
config:
providerName: spawn
- id: subagent-fork
name: '@deepseek-ai/dsh-subagent-fork'
config:
providerName: fork
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
- id: tool-subagent-fork
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
# Omitting maxInlineBytes makes the whole policy a silent no-op — always
# state it explicitly.
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. provider/model are the host default routing — the profile json's
# mapping target (user config overrides these engineering defaults).
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
config:
provider: deepseek
model: deepseek-v4-flash
# ── layer 2: transport/service ──────────────────────────────────────────────
# Plain route-registration carrier. distIndex is an assembly fact, not user
# config — AppCLIEntry resolves the frontend dist and patches it in; host and
# port arrive as CLI-flag patches over these defaults.
- id: webserver
name: '@deepseek-ai/dsh-host-webserver'
config:
host: 127.0.0.1
port: 3080
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
# Dual-face: node half scans this very tree for dshClient rows, composes
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
# module table the shell kernel constructs before cordis exists (§4.7 —
# adopted as a plugin entry by the kernel, never fetched).
- id: modules
name: '@deepseek-ai/dsh-client-modules'
# Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client.
- id: connection
name: '@deepseek-ai/dsh-client-connection'
- id: client-runtime
name: '@deepseek-ai/dsh-client-runtime'
- id: ui-theme
name: '@deepseek-ai/dsh-client-ui-theme'
- id: i18n
name: '@deepseek-ai/dsh-client-i18n'
- id: ui-layout
name: '@deepseek-ai/dsh-client-ui-layout'
- id: ui-sidebar
name: '@deepseek-ai/dsh-client-ui-sidebar'
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
- id: ui-workspace
name: '@deepseek-ai/dsh-client-ui-workspace'
- id: ui-question
name: '@deepseek-ai/dsh-client-ui-question'
- id: ui-trajectory
name: '@deepseek-ai/dsh-client-ui-trajectory'

View File

@@ -9,14 +9,23 @@
},
"files": [
"lib/bin.js",
"cordis.yml",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@cordisjs/plugin-include": "workspace:*",
"@cordisjs/plugin-loader": "workspace:*",
"@cordisjs/plugin-timer": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
@@ -24,13 +33,53 @@
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-runtime": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "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:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-storage-json": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"cordis": "^4.0.0-rc.7",
"js-yaml": "^4.2.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9"
}
}

View File

@@ -0,0 +1,265 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
* Everything here is what must exist before the Loader runs: layered env,
* the patch composition over the shipped cordis.yml (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud triple after the
* tree settles.
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import type { FiberState } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
entryId: string
configKey: string
}
/**
* The static profile→row mapping table. json is user config and wins over the
* yml engineering default per field; a json key absent from this table fails
* loud (a typo silently ignored would read as "setting has no effect").
* Developers extend deployments by adding rows here.
*/
const PROFILE_MAPPINGS: ProfileMapping[] = [
{ jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' },
{ jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' },
{ jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' },
]
// The include's YAML dialect: `!!js` scalars become expression nodes the
// Loader evaluates at entry activation. The bypass parse below must accept
// them (and passing one through a patch unchanged is legal).
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
construct: data => ({ __jsExpr: String(data) }),
})
const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
/**
* Value mirror of cordis's `FiberState` const enum members the sweep needs
* (a const enum has no runtime object to import; same rationale as the
* client-side mirror in dsh-client-web).
*/
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_PENDING = 0 as FiberState.PENDING
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
export interface AppCLIEntryOptions {
/** Absolute path of the shipped cordis.yml. */
configPath: string
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
dev: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/**
* Listen port override onto the webserver row. Web passes the --port flag
* value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
* never collide — and the printed URL still opens the live session in a
* browser).
*/
port?: number
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
workspaceRoot?: string
/** Host default provider override. Non-DeepSeek routes mount pi-ai with ambient credentials. */
provider?: string
/** Host default model override. */
model?: string
}
/**
* Boot driver for the config-tree dsh surfaces (web and headless share the
* one composition; the surfaces differ only in constructor facts): holds only
* what exists independently of (and prior to) cordis — argv facts, the
* composed patch set, and finally the root ctx.
*/
export class AppCLIEntry {
/** The root context, set by {@link run}. */
ctx!: Context
private patches: PatchOptions[] = []
private piAiProvider: string | undefined
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: layered env → patch composition → Loader include
* boot (dev row before await) → fail-loud triple.
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
this.loadEnvLayers()
this.composePatches()
await this.bootTree()
this.assertBoot()
const port = this.ctx.get('httpServer')?.port
/* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
return { ctx: this.ctx, port }
}
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
private loadEnvLayers(): void {
loadEnv('dsh', resolveDshHome())
}
/**
* Compose the patch set from the three non-yml config sources: profile
* json (user config), CLI flags, and the resolved frontend dist. Patches
* replace a row's config wholesale, so each patched row's yml static
* values are re-read here (bypass parse) and merged under the overrides.
*/
private composePatches(): void {
const rows = this.parseYmlRows()
const overrides = new Map<string, Record<string, unknown>>()
const put = (entryId: string, key: string, value: unknown): void => {
const bag = overrides.get(entryId) ?? {}
bag[key] = value
overrides.set(entryId, bag)
}
// Source 1: profile json (missing file = empty; unmapped key = loud).
const profile = this.readProfile()
for (const [key, value] of Object.entries(profile)) {
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
if (mapping === undefined) {
throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
}
put(mapping.entryId, mapping.configKey, value)
}
// Source 2: CLI flags (field set disjoint from the json mappings).
if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
if (this.options.provider !== undefined) put('api-gateway', 'provider', this.options.provider)
if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model)
const gatewayConfig = rows.get('api-gateway')?.config as Record<string, unknown> | undefined
const provider = this.options.provider ?? profile.provider ?? gatewayConfig?.provider
const model = this.options.model ?? profile.model ?? gatewayConfig?.model
if (typeof provider !== 'string' || provider === '') {
throw new Error('dsh: api-gateway provider must be a non-empty string')
}
if (provider !== 'deepseek' && (typeof model !== 'string' || model === '' || model === 'deepseek-v4-flash')) {
throw new Error(`dsh: provider ${provider} requires an explicit model`)
}
this.piAiProvider = provider === 'deepseek' ? undefined : provider
// Source 3: the frontend dist — an assembly fact of this app, never yml
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
this.patches = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
}
/** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */
private async bootTree(): Promise<void> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await ctx.loader.create({
name: 'cordis:include',
config: {
path: pathToFileURL(resolve(this.options.configPath)).href,
...this.patches.length > 0 ? { patches: this.patches } : {},
},
})
if (this.piAiProvider !== undefined) {
await ctx.loader.create({
name: '@deepseek-ai/dsh-llm-pi-ai',
config: { providers: [{ provider: this.piAiProvider }] },
})
}
if (this.options.dev) {
await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
}
this.ctx = ctx
await ctx.loader.await()
}
/**
* Fail-loud triple: assertEntriesLoaded catches import failures,
* installFailLoud catches late apply rejections, and the all-ACTIVE sweep
* below catches PENDING fibers (cordis inject waiting has no timeout).
*/
private assertBoot(): void {
installFailLoud('dsh')
assertEntriesLoaded(this.ctx, 'dsh')
const failures: string[] = []
for (const entry of this.ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled) continue
const state = entry.fiber.state
if (state === FIBER_ACTIVE) continue
if (state === FIBER_PENDING) {
const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined)
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
}
}
if (failures.length > 0) {
throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
private parseYmlRows(): Map<string, { config?: unknown }> {
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
const rows = new Map<string, { config?: unknown }>()
for (const row of doc as { id?: string; config?: unknown }[]) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
return rows
}
/** Profile json under cwd; read-only — never created here, absent = no user config. */
private readProfile(): Record<string, unknown> {
let raw: string
try {
raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
throw error
}
const parsed: unknown = JSON.parse(raw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
}
return parsed as Record<string, unknown>
}
/** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */
private resolveDistIndex(): string {
const require = createRequire(import.meta.url)
try {
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
}
}
}

View File

@@ -1,18 +1,20 @@
/**
* `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic
* injection (InProcessApiClient over the host handler, so the full carrier
* chain — wire serialization, zod, SSE framing — really runs; this is the
* protocol's second real consumer). No HTTP server, no port, no dist
* resolution. Runs one task turn, prints the final assistant text, exits
* (completed → 0, else 1).
* `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
* boots the same cordis.yml as `dsh web` (port 0, so parallel runs never
* collide), then in-process isomorphic injection (InProcessApiClient over
* toFetchHandler(ctx.apiProxy), so the full carrier chain — wire
* serialization, zod, SSE framing — really runs). The printed URL opens the
* live session in a browser while the task runs. Runs one task turn, prints
* the final assistant text, exits (completed → 0, else 1).
*/
import { parseArgs } from 'node:util'
import { startHost } from '@deepseek-ai/dsh-host-runtime'
import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy'
import { fileURLToPath } from 'node:url'
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AppCLIEntry } from './app-cli-entry.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
@@ -78,15 +80,18 @@ export async function runHeadless(argv: string[]): Promise<void> {
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: false,
},
const entry = new AppCLIEntry({
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
dev: false,
port: 0,
})
const api = new InProcessApiClient(host.handler)
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), () => host.dispose())
const created = await unwrap(await api.sessions.create({}), dispose)
// Open the stream before prompting so no frame is lost — kept in this order
// even though in-process delivery has no race, so the code survives a move
@@ -99,11 +104,11 @@ export async function runHeadless(argv: string[]): Promise<void> {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
}), () => host.dispose())
}), dispose)
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await host.dispose()
await dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
}

View File

@@ -1,203 +1,72 @@
/**
* `dsh web` — the web-shape assembly: startHost + dist resolution +
* startWebServer + the URL line + signal wiring. Mixing host and carrier
* concerns is this app module's job (packages stay single-sided).
* `dsh web` — thin bin over the config-tree boot: parse argv, run
* AppCLIEntry, print the URL line, wire signals. All composition lives in
* cordis.yml; all boot glue lives in AppCLIEntry.
*/
import { parseArgs } from 'node:util'
import { networkInterfaces } from 'node:os'
import { createRequire } from 'node:module'
import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
// --- Client composition (composition decisions live in the composing app) ---
// The composition layer owns one decision: which plugin packages mount (the
// roster). Dependency edges and the boot prefetch tier live in each package's
// dshClient declaration.
/**
* Dev-only plugin: the client HMR driver. Whether it composes in is a
* deployment decision — the dev graph includes its row, the prod graph does
* not mount it at all.
*/
const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr'
/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */
const CLIENT_BUNDLE_POLL_MS = 500
/** The client plugin roster (flat; per-row boot behavior comes from manifests). */
const CLIENT_PACKAGES = [
'@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
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
export async function runWeb(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: {
host: { type: 'string', default: LOOPBACK_HOST },
port: { type: 'string', default: '3080' },
'max-request-body-bytes': { type: 'string' },
host: { type: 'string' },
port: { type: 'string' },
dev: { type: 'boolean', default: false },
'workspace-root': { type: 'string' },
provider: { type: 'string' },
model: { type: 'string' },
dev: { type: 'boolean', default: false },
},
allowPositionals: false,
})
if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
process.stderr.write(
`dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
)
process.exit(1)
}
const hostAddress = values.host
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
process.exit(1)
}
const configuredMaxRequestBodyBytes = values['max-request-body-bytes'] === undefined
? undefined
: Number(values['max-request-body-bytes'])
if (configuredMaxRequestBodyBytes !== undefined
&& (!Number.isInteger(configuredMaxRequestBodyBytes)
|| configuredMaxRequestBodyBytes < 1)) {
process.stderr.write(
`dsh web: invalid --max-request-body-bytes ${values['max-request-body-bytes']}\n`,
)
process.exit(1)
}
// Default routing: --provider deepseek (implicit) keeps the DeepSeek-only
// assembly; any other --provider additionally mounts that pi-ai catalog
// route (credentials via the provider's ambient discovery, e.g.
// ANTHROPIC_API_KEY) so visual-capable models are reachable from the shipped
// Web app. A non-default provider requires an explicit --model — this shell
// has no evidence for inventing another provider's default.
const provider = values.provider ?? 'deepseek'
if (provider !== 'deepseek' && values.model === undefined) {
process.stderr.write(`dsh web: --provider ${provider} requires an explicit --model\n`)
process.exit(1)
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught
// by design); an unknown --provider fails the pi-ai catalog check the same way.
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: { maxBytes: 65_536 },
sessionTitleLlm: true,
...provider === 'deepseek' ? {} : { piAiProviders: [{ provider }] },
...values.provider === undefined ? {} : { provider: values.provider },
...values.model === undefined ? {} : { model: values.model },
},
})
const attachments = host.ctx.get('attachments')
if (attachments === undefined) throw new Error('dsh web: attachment service unavailable')
const maxRequestBodyBytes = configuredMaxRequestBodyBytes
?? Math.ceil(attachments.imageLimits.maxMessageImageBytes * 4 / 3)
+ REQUEST_ENVELOPE_HEADROOM_BYTES
// Client plugin chain: in-memory Loader tree over the composed roster, then
// the registry that feeds the __DSH_BOOT__ entry graph and
// /plugins/<id>/client.js. All row content comes from dshClient discovery
// over the mounted roster (dev adds the HMR driver row and turns on the
// bundle watch that drives rebuilt frames).
const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []]
const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url)
const webPlugins = createHostWebPluginRegistry({
ctx: host.ctx,
loader: mounted.loader,
resolvePkgJson: mounted.resolvePkgJson,
onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {},
})
if (values.dev) {
// Dev visibility (the registry is a library and never prints): list what
// the bundle watch covers, then log every observed rebuild. This is a
// second onRebuilt subscription — the SSE relay inside the webserver is
// unaffected (multicast).
const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev]))
const bundlePaths = [...revs.keys()]
.map(id => webPlugins.clientPath(id))
.filter((path): path is string => path !== undefined)
console.log(
`dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`,
)
webPlugins.onRebuilt((id, rev) => {
console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`)
revs.set(id, rev)
})
}
// Published so the webserver invariant companion can audit manifest/bundle
// consistency; nothing else reads this key.
host.ctx.reflect.provide('webPlugins', webPlugins)
// Dist location is workspace knowledge of this app: resolved through
// @deepseek-ai/dsh-frontend's package exports, not configured.
const require = createRequire(import.meta.url)
let distIndex: string
try {
distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n')
await host.dispose()
process.exit(1)
}
let exiting = false
async function shutdown(code: number): Promise<void> {
if (exiting) return
exiting = true
try {
await server.close()
await host.dispose()
} finally {
process.exit(code)
let port: number | undefined
if (values.port !== undefined) {
port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
process.exit(1)
}
}
let server: Awaited<ReturnType<typeof startWebServer>>
try {
server = await startWebServer(
{
host: hostAddress,
port,
distIndex,
apiHandler: host.handler,
maxRequestBodyBytes,
webPlugins,
},
(err: Error) => {
process.stderr.write(`dsh web: ${String(err)}\n`)
void shutdown(1)
},
)
} catch (error: unknown) {
// listen failed (EADDRINUSE…): no server to close, dispose the host directly.
process.stderr.write(`dsh web: ${String(error)}\n`)
await host.dispose()
process.exit(1)
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
dev: values.dev,
...values.host !== undefined ? { host: values.host } : {},
...port !== undefined ? { port } : {},
...values['workspace-root'] !== undefined ? { workspaceRoot: values['workspace-root'] } : {},
...values.provider !== undefined ? { provider: values.provider } : {},
...values.model !== undefined ? { model: values.model } : {},
})
const { ctx, port: boundPort } = await entry.run()
let exiting = false
const shutdown = (code: number): void => {
if (exiting) return
exiting = true
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const lan = hostAddress === ALL_INTERFACES_HOST
const lanCandidate = values.host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined
const localUrl = `http://${LOOPBACK_HOST}:${server.port}`
console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
process.on('SIGTERM', () => { void shutdown(0) })
process.on('SIGINT', () => { void shutdown(130) })
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
}

View File

@@ -14,9 +14,6 @@
{
"path": "../../packages/host/apiproxy"
},
{
"path": "../../packages/host/runtime"
},
{
"path": "../../packages/host/webserver"
},

View File

@@ -24,7 +24,6 @@
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",

View File

@@ -3,8 +3,8 @@
* loader holding, module-table seeding, AppRoot gate, plugin assembly — lives
* in @deepseek-ai/dsh-client-web; this file only finds the mount point.
*/
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const el = document.getElementById('root')
if (el === null) throw new Error('web app: missing #root')
bootWebShell(el)
void new AppWebEntry(el).run()

View File

@@ -1,10 +1,6 @@
/**
* Browser stand-in for `node:module`, mapped by the vite alias in
* vite.config.ts (design §2.4). The vendored Loader's internal.ts imports
* `createRequire` at module scope but only calls it inside
* `ModuleLoader.fromInternal()`, whose version probe is compiled to the
* `"0.0.0"` define in the browser build — so this throw is a fail-loud
* tripwire for any path that would genuinely need Node's module machinery.
* Browser stand-in for `node:module`. `createRequire` is unreachable in the
* configured loader path and fails loud if that assumption changes.
*/
/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */

View File

@@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules'
import { bootWebShell } from '@deepseek-ai/dsh-client-web'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
@@ -14,6 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
@@ -72,26 +73,29 @@ afterEach(() => {
function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } {
const tree = screen.getByRole('tree', { name: 'Sessions' })
const sidebar = within(tree).getByText(label).textContent ?? ''
const breadcrumb = within(screen.getByRole('navigation', { name: '会话层级' }))
const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' }))
.getByRole('button', { name: label }).textContent ?? ''
return { sidebar, breadcrumb, documentTitle: document.title }
}
it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => {
it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => {
const root = document.querySelector<HTMLElement>('#root')
if (root === null) throw new Error('snapshot root missing')
act(() => {
unmount = bootWebShell(root, {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }
})
const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 })
const projectRow = projectLabel.closest<HTMLElement>('[role="treeitem"]')
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
const projectCount = await within(tree).findByText('4 sessions')
const projectRow = projectCount.closest<HTMLElement>('[role="treeitem"]')
if (projectRow === null) throw new Error('fixture project row missing')
fireEvent.click(projectRow)

View File

@@ -1,370 +0,0 @@
// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
// chromium. First describe: graph injection + the fail-loud half. Second
// describe: the settled success pass — all nine REAL tsdown bundles load
// through the module system + vendored Loader chain in ?fixture mode (the
// infrastructure four ride the immediately prefetch tier, the UI rows fetch
// on demand), the three-column frame appears in one flip, and the resident
// question completes through the real UI stack. The full model round lands
// in smoke-real under the W5 real-host standard.
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { startWebServer } from '@deepseek-ai/dsh-host-webserver'
import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver'
import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
const bundlePath = (dir: string): string =>
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar'
const FIXTURE_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg=='
/** id ↔ bundle table for the success pass (the complete Web UI assembly). */
const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true },
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true },
{ id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
{ id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
const row = (id: string, extra?: Partial<WebBootEntry>): WebBootEntry =>
({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra })
const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, {
...(p.inject !== undefined ? { inject: p.inject } : {}),
...(p.immediately === true ? { immediately: true } : {}),
}))
/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */
const FAIL_GRAPH: WebBootGraph = {
rev: 'e2e-fail',
entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')],
}
/** Graph for the success pass: the complete assembly. */
const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows }
/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */
function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap<string, string>) {
return {
graph: () => graph,
clientPath: (id: string) => byId.get(id),
onRebuilt: () => () => undefined,
}
}
describe('web boot chain (keyless, real carrier)', () => {
let server: Awaited<ReturnType<typeof startWebServer>>
let browser: Browser
let page: Page
const pageErrors: string[] = []
beforeAll(async () => {
requireDist()
const port = await probeFreePort()
const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) }
server = await startWebServer({
host: '127.0.0.1',
port,
distIndex: DIST_INDEX,
apiHandler,
maxRequestBodyBytes: 32 * 1024 * 1024,
webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS),
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
browser = await chromium.launch()
page = await browser.newPage()
page.on('pageerror', e => pageErrors.push(String(e)))
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' })
})
afterAll(async () => {
await browser?.close()
await server?.close()
})
it('GET / injects the entry graph verbatim', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
expect(boot).toEqual(FAIL_GRAPH)
})
it('serves a real bundle through the plugins endpoint', async () => {
const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`)
expect(res.status()).toBe(200)
expect(await res.text()).toContain('window.__ModuleLoader__.load')
})
it('boots to the loading page and fail-louds the absent entry', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
// The real UI must not have flipped in: the gate opens only on settled.
expect(await page.locator('[class*="frame"]').count()).toBe(0)
})
it('applies the token sheets before any plugin CSS', async () => {
const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
expect(family.trim().length).toBeGreaterThan(0)
})
})
describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
let server: Awaited<ReturnType<typeof startWebServer>>
let browser: Browser
let page: Page
const pageErrors: string[] = []
beforeAll(async () => {
requireDist()
const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map(m => m.dir).join(', ')}`)
const port = await probeFreePort()
// ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
server = await startWebServer({
host: '127.0.0.1',
port,
distIndex: DIST_INDEX,
apiHandler,
maxRequestBodyBytes: 32 * 1024 * 1024,
webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS),
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
browser = await chromium.launch()
page = await browser.newPage()
page.on('pageerror', e => pageErrors.push(String(e)))
await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' })
})
afterAll(async () => {
await browser?.close()
await server?.close()
})
it('settles and flips to the three-column frame in one pass', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled'))
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
// Loading page is gone; the grid carries the three tracks.
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
expect(template.split(' ').length).toBe(3)
})
it('every plugin CSS landed with its ownership tag', async () => {
const owners = await page.evaluate(() =>
[...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
expect(owners).toContain(LAYOUT_ID)
expect(owners).toContain(SIDEBAR_ID)
})
it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail'))
const frame = page.locator('[class*="frame"]')
const firstTrack = async (): Promise<string> => (await frame.evaluate(
el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
// The tracks transition on the deepsuite curve; assert the animated
// settle rather than an instant jump.
const settledTrack = async (px: string): Promise<void> => {
await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
}
// The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome.
const brand = () => page.locator('[class*="brand"]').count()
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
// Mid-collapse the wide chrome is still mounted, fading — not swapped out.
expect(await brand()).toBe(1)
await settledTrack('56px')
await expect.poll(brand, { timeout: 2000 }).toBe(0)
for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
}
await page.getByRole('button', { name: 'Open sidebar' }).click()
await settledTrack('280px')
await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
// Rail search: collapse again, the search control expands and lands in the box.
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
await settledTrack('56px')
await page.getByRole('button', { name: 'Search sessions' }).click()
await settledTrack('280px')
// Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it.
await expect.poll(() => page.evaluate(() =>
(document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search')
})
it('renders file tool rows and expands fixture reasoning from either click target', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure'))
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"][aria-selected]').first().click()
const thinkRoot = page.locator('[data-variant="think"]').first()
const think = thinkRoot.getByRole('button')
await think.waitFor({ state: 'visible', timeout: 10_000 })
expect(await think.getAttribute('aria-expanded')).toBe('false')
await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click()
expect(await think.getAttribute('aria-expanded')).toBe('true')
expect(await thinkRoot.locator(':scope > div').count()).toBe(2)
await think.getByText('Think', { exact: true }).click()
expect(await think.getAttribute('aria-expanded')).toBe('false')
const editRoot = page.locator('[data-variant="edit"]').first()
await editRoot.waitFor({ state: 'visible', timeout: 10_000 })
expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1)
expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1)
const writeRoot = page.locator('[data-variant="write"]').first()
await writeRoot.waitFor({ state: 'visible', timeout: 10_000 })
expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1)
expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1)
})
it('renders historical user and assistant images and opens the original on double-click', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-fixture-images'))
const workspace = page.getByRole('treeitem', { name: /fixture 3 sessions/ })
if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click()
await page.locator('[role="treeitem"][aria-selected]').first().click()
await page.waitForSelector('text=历史用户图片', { timeout: 15_000 })
await page.waitForSelector('text=结构化模型图片', { timeout: 15_000 })
const images = page.getByTitle('双击查看原图')
await expect.poll(() => images.count()).toBeGreaterThanOrEqual(2)
const first = images.first()
const box = await first.boundingBox()
expect(box?.width).toBeLessThanOrEqual(240)
expect(box?.height).toBeLessThanOrEqual(240)
await first.dblclick()
const preview = page.getByRole('dialog', { name: '原图预览' })
await preview.waitFor({ state: 'visible' })
await page.keyboard.press('Escape')
await preview.waitFor({ state: 'detached' })
})
it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream'))
await page.getByRole('button', { name: 'New session', exact: true }).click()
const input = page.locator('textarea[placeholder]')
await input.waitFor({ timeout: 15_000 })
await input.fill('render markdown')
await page.getByRole('button', { name: '发送' }).click()
const streaming = page.locator('[data-streaming="true"]')
await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 })
await streaming.waitFor({ state: 'detached', timeout: 15_000 })
const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' })
expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1')
expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1)
const external = page.getByRole('link', { name: 'DeepSeek' })
expect(await external.getAttribute('target')).toBe('_blank')
expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
})
it('pastes and drops images into the composer, then sends them as durable history', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-fixture-image-paste'))
const textarea = page.locator('textarea')
await textarea.waitFor({ state: 'visible' })
if (await page.getByRole('button', { name: '停止' }).count() > 0) {
await page.getByRole('button', { name: '停止' }).click()
}
await expect.poll(() => textarea.isEnabled()).toBe(true)
await textarea.evaluate((element, png) => {
const bytes = Uint8Array.from(atob(png), character => character.charCodeAt(0))
const transfer = new DataTransfer()
transfer.items.add(new File([bytes], 'clipboard.png', { type: 'image/png' }))
element.dispatchEvent(new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: transfer,
}))
}, FIXTURE_PNG)
const rail = page.getByLabel('待发送图片')
await rail.waitFor({ state: 'visible' })
await rail.getByTitle('双击查看原图').dblclick()
const preview = page.getByRole('dialog', { name: '原图预览' })
await preview.waitFor({ state: 'visible' })
await page.keyboard.press('Escape')
await preview.waitFor({ state: 'detached' })
await page.getByRole('button', { name: '发送' }).click()
await rail.waitFor({ state: 'detached' })
await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(1)
await page.getByRole('button', { name: '停止' }).click()
await expect.poll(() => textarea.isEnabled()).toBe(true)
await textarea.evaluate((element, png) => {
const bytes = Uint8Array.from(atob(png), character => character.charCodeAt(0))
const transfer = new DataTransfer()
transfer.items.add(new File([bytes], 'dropped.png', { type: 'image/png' }))
const card = element.closest('[class*="card"]')
card?.dispatchEvent(new DragEvent('dragenter', {
bubbles: true,
cancelable: true,
dataTransfer: transfer,
}))
card?.dispatchEvent(new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer: transfer,
}))
}, FIXTURE_PNG)
await rail.waitFor({ state: 'visible' })
await rail.getByAltText('dropped.png').waitFor({ state: 'visible' })
await page.getByRole('button', { name: '发送' }).click()
await rail.waitFor({ state: 'detached' })
await expect.poll(() => page.getByTitle('双击查看原图').count()).toBeGreaterThanOrEqual(2)
})
it('renders and completes the resident question through the composer slot', async () => {
onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
const sessionTree = page.getByRole('tree', { name: 'Sessions' })
const projectRow = sessionTree.getByRole('treeitem').filter({ hasText: '3 sessions' })
if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click()
await sessionTree.getByText('Fixture 历史会话', { exact: true }).click()
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: 15_000 })
expect({
question: await composer.getByRole('heading').innerText(),
progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
options: await composer.getByRole('radio').allTextContents(),
custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
}).toMatchInlineSnapshot(`
{
"custom": "其他,请填写自定义答案",
"options": [
"1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
"2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
"3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
],
"progress": "1 / 3",
"question": "你现在更想招哪类 Agent/Harness 候选人?",
}
`)
await composer.getByRole('radio', { name: '工程落地型' }).click()
await composer.getByText('2 / 3', { exact: true }).waitFor()
await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
await composer.getByRole('checkbox', { name: '系统设计' }).click()
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
await composer.waitFor({ state: 'detached' })
const restoredInput = page.locator('textarea[placeholder]')
await restoredInput.waitFor()
expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
})
it('stayed clean: no page errors across the whole load chain', () => {
expect(pageErrors).toEqual([])
})
})

View File

@@ -333,10 +333,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.`
await input.fill(prompt)
await input.press('Enter')
// startSession chain: session mounts, composer moves to the bottom.
// Regression pin (P0, 585671106): this send used to white-screen the tree
// (scope tag lost to a duplicate inlined runtime instance) — body going
// near-empty here means that class of bug is back.
// The first send must keep the session tree mounted; a near-empty body
// reveals a duplicate runtime bundle with incompatible scope tags.
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
expect(pageErrors).toEqual([])
await page.waitForFunction(

View File

@@ -16,10 +16,7 @@ export function requireDist(): void {
}
}
/**
* OS-assigned free port, released before use. startWebServer echoes
* options.port instead of the bound one, so passing 0 directly is unusable.
*/
/** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */
export function probeFreePort(): Promise<number> {
return new Promise((resolvePort, reject) => {
const probe = createServer()

View File

@@ -0,0 +1,323 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
dir: 'ui-workspace',
url: '/plugins/ui-workspace.js',
rev: 'fx',
inject: [
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-sidebar',
],
},
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
const bundles = new Map(PLUGINS.map(plugin => [
plugin.url,
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
]))
interface FixtureWindow extends Window {
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
__ModuleLoader__?: unknown
}
class ResizeObserverStub {
observe(): void {}
disconnect(): void {}
unobserve(): void {}
}
const win = window as FixtureWindow
let unmount: (() => void) | undefined
beforeEach(() => {
localStorage.clear()
document.title = 'DeepSeek Harness'
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
setTimeout(() => { callback(0) }, 0) as unknown as number)
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
})
afterEach(() => {
act(() => { unmount?.() })
unmount = undefined
cleanup()
delete win.__DSH_BOOT__
delete win.__ModuleLoader__
delete (globalThis as Record<string, unknown>).__fxTiming
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
document.title = ''
history.replaceState(null, '', '/')
vi.unstubAllGlobals()
})
/** Boot the complete built client graph against one keyless fixture branch. */
function boot(search: string): void {
history.replaceState(null, '', `/${search}`)
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
fetchBundle: (url) => {
const code = bundles.get(url)
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
},
executeBundle: (code) => { (0, eval)(code) },
})
void entry.run()
unmount = () => { entry.dispose() }
})
}
/** Recreate the built client graph while preserving browser-persistent state. */
function refresh(search: string): void {
act(() => { unmount?.() })
unmount = undefined
cleanup()
delete win.__DSH_BOOT__
delete win.__ModuleLoader__
delete (globalThis as Record<string, unknown>).__fxTiming
document.body.innerHTML = ''
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
boot(search)
}
/** Collapse decorative whitespace while preserving the text a user sees. */
function visibleText(element: Element): string {
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
}
/** Identify the interactive Workspace chip by its menu contract. */
function workspaceChip(): HTMLElement {
const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
.find(element => element.getAttribute('aria-haspopup') === 'menu')
if (chip === undefined) throw new Error('Workspace chip missing')
return chip
}
/** Wait for the runtime-owned controlled input to echo a browser edit. */
async function setComposerText(composer: HTMLElement, value: string): Promise<void> {
fireEvent.change(composer, { target: { value } })
await waitFor(() => { expect((composer as HTMLTextAreaElement).value).toBe(value) })
}
it('starts a writable page-local draft without inventing a sidebar Workspace', async () => {
boot('?fixture=empty')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const tree = screen.getByRole('tree', { name: 'Sessions' })
await setComposerText(composer, 'keep this local')
expect({
headline: visibleText(screen.getByText("Let's start building")),
workspaceDraft: visibleText(workspaceChip()),
sidebar: visibleText(tree),
composerDisabled: (composer as HTMLTextAreaElement).disabled,
prompt: (composer as HTMLTextAreaElement).value,
}).toMatchInlineSnapshot(`
{
"composerDisabled": false,
"headline": "Let's start building",
"prompt": "keep this local",
"sidebar": "No sessions yet",
"workspaceDraft": "workspace",
}
`)
})
it('creates a real empty Workspace immediately and focuses its Session draft', async () => {
boot('?fixture=empty')
await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const workspaceSection = screen.getByText('Workspaces').parentElement
if (workspaceSection === null) throw new Error('Workspace section missing')
fireEvent.click(within(workspaceSection).getByRole('button', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
target: { value: 'nova' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
const tree = await screen.findByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
const draft = within(tree).getByText('New session').closest('[role="treeitem"]')
if (group === null || draft === null) throw new Error('created Workspace projection missing')
expect({
workspace: visibleText(group),
draft: visibleText(draft),
draftSelected: draft.getAttribute('aria-selected'),
composerWorkspace: visibleText(workspaceChip()),
}).toMatchInlineSnapshot(`
{
"composerWorkspace": "nova",
"draft": "New session",
"draftSelected": "true",
"workspace": "nova1 session",
}
`)
})
it('drops the page-local draft on refresh while retaining real Workspaces and Sessions', async () => {
boot('?fixture')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const tree = screen.getByRole('tree', { name: 'Sessions' })
await setComposerText(composer, 'discard this page-local draft')
const beforeGroup = within(tree).getByText('4 sessions').closest('[role="treeitem"]')
if (beforeGroup === null) throw new Error('fixture Workspace projection missing before refresh')
const before = {
workspace: visibleText(beforeGroup),
draft: visibleText(within(tree).getByText('New session')),
prompt: (composer as HTMLTextAreaElement).value,
}
refresh('?fixture')
const refreshedComposer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
const refreshedTree = screen.getByRole('tree', { name: 'Sessions' })
const afterGroup = within(refreshedTree).getByText('4 sessions').closest('[role="treeitem"]')
if (afterGroup === null) throw new Error('fixture Workspace projection missing after refresh')
expect({
before,
after: {
workspace: visibleText(afterGroup),
replacementDraft: visibleText(within(refreshedTree).getByText('New session')),
prompt: (refreshedComposer as HTMLTextAreaElement).value,
},
}).toMatchInlineSnapshot(`
{
"after": {
"prompt": "",
"replacementDraft": "New session",
"workspace": "fixture4 sessions",
},
"before": {
"draft": "New session",
"prompt": "discard this page-local draft",
"workspace": "fixture4 sessions",
},
}
`)
})
it('keeps a published Session with only cwd membership evidence in Ungrouped', async () => {
boot('?fixture&fixtureAttach=fail')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
await setComposerText(composer, 'keep this cwd-only session')
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('Ungrouped')).toBeDefined() }, { timeout: 10_000 })
const workspaceGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
const ungroupedGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
const ungroupedSection = ungroupedGroup?.parentElement
if (workspaceGroup === null || ungroupedGroup === null || ungroupedSection === null || ungroupedSection === undefined) {
throw new Error('Workspace or Ungrouped projection missing')
}
const session = within(ungroupedSection).getByRole('treeitem', { selected: true })
const retained = screen.getByDisplayValue('keep this cwd-only session')
expect({
workspace: visibleText(workspaceGroup),
ungrouped: visibleText(ungroupedGroup),
session: within(session).getByText('fixture', { exact: true }).textContent,
sessionSelected: session.getAttribute('aria-selected'),
prompt: (retained as HTMLTextAreaElement).value,
}).toMatchInlineSnapshot(`
{
"prompt": "keep this cwd-only session",
"session": "fixture",
"sessionSelected": "true",
"ungrouped": "Ungrouped1 session",
"workspace": "fixture3 sessions",
}
`)
})
it('materializes the automatic Workspace and Session on the first successful send', async () => {
boot('?fixture=empty')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
await setComposerText(composer, 'build a lighthouse')
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
await screen.findByText('build a lighthouse', { exact: true }, { timeout: 10_000 })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
const session = within(tree).getByRole('treeitem', { selected: true })
if (group === null) throw new Error('materialized Workspace projection missing')
expect({
workspace: visibleText(group),
session: within(session).getByText('workspace', { exact: true }).textContent,
sessionSelected: session.getAttribute('aria-selected'),
promptVisible: screen.getByText('build a lighthouse', { exact: true }).textContent,
}).toMatchInlineSnapshot(`
{
"promptVisible": "build a lighthouse",
"session": "workspace",
"sessionSelected": "true",
"workspace": "workspace1 session",
}
`)
})
it('keeps the published Workspace, Session, and unsent prompt after rejection', async () => {
boot('?fixture=empty&fixturePrompt=reject')
const composer = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 })
await setComposerText(composer, 'do not lose this')
fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
const retained = screen.getByDisplayValue('do not lose this')
const tree = screen.getByRole('tree', { name: 'Sessions' })
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
const session = within(tree).getByRole('treeitem', { selected: true })
if (group === null) throw new Error('rejected-send Workspace projection missing')
expect({
workspace: visibleText(group),
session: within(session).getByText('workspace', { exact: true }).textContent,
error: visibleText(alert),
prompt: (retained as HTMLTextAreaElement).value,
}).toMatchInlineSnapshot(`
{
"error": "Message send failed: agent-busy: fixture: prompt rejected before acceptance",
"prompt": "do not lose this",
"session": "workspace",
"workspace": "workspace1 session",
}
`)
})

View File

@@ -21,9 +21,6 @@
{
"path": "../../packages/client/web"
},
{
"path": "../../packages/host/webserver"
},
{
"path": "../../packages/client/modules"
}

View File

@@ -22,7 +22,7 @@ export default defineConfig({
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
],
},
define: {