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:
@@ -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
246
apps/cli/cordis.yml
Normal 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'
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
265
apps/cli/src/app-cli-entry.ts
Normal file
265
apps/cli/src/app-cli-entry.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../packages/host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/webserver"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
323
apps/web/tests/workspace-flow.snapshot.ts
Normal file
323
apps/web/tests/workspace-flow.snapshot.ts
Normal 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",
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -21,9 +21,6 @@
|
||||
{
|
||||
"path": "../../packages/client/web"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/modules"
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user