Merge remote-tracking branch 'origin/master' into worktree/web-session-titles

# Conflicts:
#	.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/selection-survival.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-layout/tests/service.spec.ts
#	packages/client/ui-sidebar/tests/apply.spec.tsx
#	packages/client/ui-sidebar/tests/store.spec.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/client/web/src/app.tsx
#	packages/client/web/tests/boot.spec.tsx
#	packages/host/runtime/README.md
#	packages/host/runtime/tests/host-runtime.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-23 18:39:48 +08:00
285 changed files with 11631 additions and 6072 deletions

View File

@@ -1,6 +1,6 @@
# `@deepseek-ai/dsh`
The `dsh` command-line entry, following the `apps/` assembly tier proposed by the `dsh web` PR (#443): `apps/*` are product assemblies over `packages/*` libraries. This branch ships one surface — plain `dsh [config.yml]` boots the interactive TUI coding agent — and reserves the `web` and `-p`/`--prompt` subcommands for that PR so the dispatch merges as a union.
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
The TUI surface:
@@ -10,6 +10,8 @@ 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 and loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget.
## Install (developer machine)
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:

View File

@@ -78,7 +78,12 @@ 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' } })
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: false,
},
})
const api = new InProcessApiClient(host.handler)
const created = await unwrap(await api.sessions.create({}), () => host.dispose())

View File

@@ -36,7 +36,12 @@ export async function runWeb(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' } })
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: { maxBytes: 65_536 },
},
})
// Web UI plugin chain: in-memory Loader tree over the eight UI packages,
// then the registry that feeds __DSH_BOOT__ and /plugins/<id>/client.js.

View File

@@ -1,10 +1,10 @@
// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
// registry surface + __DSH_BOOT__ injection + built shell dist in a real
// chromium. First describe: manifest injection + fail-loud half. Second
// describe: the settled success pass — five REAL tsdown bundles (the
// infrastructure four + layout) load through the DI chain in ?fixture mode
// and the three-column frame appears in one flip. The full conversation
// round lands in smoke-real under the W5 real-host standard.
// chromium. First describe: manifest injection + static serving. Second
// describe: the settled success pass — seven REAL tsdown bundles (the
// infrastructure four + layout/sidebar/conversation) load through the DI
// chain in ?fixture mode and the three-column frame appears in one flip. The
// full conversation 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'
@@ -17,13 +17,15 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo
const bundlePath = (dir: string): string =>
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
/** id ↔ bundle table for the success pass (immediately four + layout). */
/** id ↔ bundle table for the success pass (immediately four + layout/sidebar). */
const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], 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', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
]
/** Manifest served by the fake registry: one live bundle row, one missing row. */
@@ -76,22 +78,13 @@ describe('web boot chain (keyless, real carrier)', () => {
expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin')
})
it('boots to the loading page and fail-louds the absent plugin', 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, five real bundles, ?fixture)', () => {
describe('web boot chain success pass (keyless, seven real bundles, ?fixture)', () => {
const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
let server: Awaited<ReturnType<typeof startWebServer>>
let browser: Browser
@@ -141,6 +134,66 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', (
const owners = await page.evaluate(() =>
[...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar')
})
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)
}
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
// Mid-collapse the wide chrome is still mounted, fading — not swapped out.
expect(await page.locator('text=HARNESS').count()).toBe(1)
await settledTrack('56px')
await expect.poll(() => page.locator('text=HARNESS').count(), { timeout: 2000 }).toBe(0)
for (const name of ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
}
await page.getByRole('button', { name: 'Expand sidebar' }).click()
await settledTrack('300px')
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('300px')
const focused = await page.evaluate(() =>
(document.activeElement as HTMLInputElement | null)?.placeholder ?? '')
expect(focused).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('stayed clean: no page errors across the whole load chain', () => {

View File

@@ -15,7 +15,8 @@
// and theme after, reload recovery last. Tests run sequentially in-file.
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -57,6 +58,25 @@ function waitForReadyLine(child: ChildProcess): Promise<string> {
})
}
async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<T> {
const response = await fetch(`${baseUrl}/api/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: `smoke-${method}`,
method,
payload,
}),
})
if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
const body = await response.json() as {
result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
}
if (!body.result.ok) throw new Error(`${method} failed: ${body.result.error.code}: ${body.result.error.message}`)
return body.result.value
}
/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
async function screen(page: Page, name: string): Promise<void> {
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
@@ -117,6 +137,91 @@ describe('dsh web keyless CLI smoke', () => {
rmSync(sessionsDir, { recursive: true, force: true })
}
})
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
mkdirSync(join(workspace, '.git'))
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
resolveProviderRequest = resolve
})
const provider = createServer((request, response) => {
let body = ''
request.setEncoding('utf8')
request.on('data', (chunk: string) => { body += chunk })
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
'data: {"choices":[{"delta":{"content":"done"}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
].join('\n\n'))
})
})
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
const address = provider.address()
if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
const child = spawn(
process.execPath,
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
{
cwd: workspace,
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-web-workspace',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_HOME: join(workspace, '.dsh'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
stdio: ['ignore', 'pipe', 'pipe'],
},
)
try {
const baseUrl = await waitForReadyLine(child)
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'go' }],
})
const captured = await Promise.race([
providerRequest,
new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
}),
])
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
expect(workspaceMessage).toMatchInlineSnapshot(`
{
"content": "<system-reminder>
The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
Instructions from: AGENTS.md
web-workspace-context-probe
</system-reminder>",
"role": "user",
}
`)
} finally {
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
: Promise.resolve()
if (child.exitCode === null) child.kill('SIGTERM')
await closed
await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
rmSync(workspace, { recursive: true, force: true })
}
})
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
@@ -226,10 +331,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
await input.press('Enter')
// Wait for the tool ROW, not response text (the reply echoes any marker).
// bash renders through the third-party sample registration (data-sample) —
// that IS the differential-rendering acceptance; the generic path renders
// data-variant rows with the handler on the data-clickable inner row.
const toolRow = page.locator('[data-sample], [data-variant] [data-clickable]').first()
// Bash renders through the third-party sample registration. Match that
// exact row: other clickable variants (for example Think disclosure)
// may precede the tool call in document order.
const toolRow = page.locator('[data-sample="bash-global"]')
await toolRow.waitFor({ timeout: 120_000 })
await screen(page, '08-bash-round')
expect(await detailsTrack(page)).toBe(0)