fix(web): return every service a host row injects, and run the browser lane

The browser e2e lane had been failing wholesale since this stack moved the
agent plane into presets, and nothing caught it: 34 of 48 files. Two of the
causes are product defects, not test breakage.

`bashEnv` goes back to the host plane. `apps/cli/src/web.ts` injects it to
publish `DSH_WEB_URL`/`DSH_WEB_MODE`, so the earlier note that "nothing outside
the agent plane injects bashEnv" was simply wrong — behind a preset's `shell`
realm those variables reached no shell at all, and a `dsh web` agent could not
find the address of its own interface. This is the same criterion that returned
`subagents`: a host row that injects a service resolves before any session
exists and has no agent to key by, so the service is host-plane. `tool-bash`
consumes the host registry from inside the preset, which works because an
agent context chains to the host; only the reverse is invisible.

`tool-subagent-report` goes back with it. It is not a tool this agent calls: it
registers a continuable SETUP on the host `subagents` singleton, and that list
is not scope-aware. One copy per mounted preset meant every child was handed
`report` once per live session, so the second registration threw and a cold
subagent resume failed with `subagent-not-resumable` — a diagnostic three
layers removed from the cause.

The lane's own composition facts follow. Skill roots resolve inside a preset
now, a subtree include patches cannot reach, so the scaffold pins the roots'
documented environment fallback for its whole lifetime rather than for the boot
— presets mount per session. Without it the developer's real `~/.dsh/skills`
enters replay requests and goldens while CI sees none. The `apps/cli`
composition test pins `storage-json` for the same reason: unpinned it wrote,
and then read back, the developer's own `~/.dsh/storages/`.

Three tests now address through an agent what they used to read off the root
context, because that is where the thing lives: the tool catalog, the skill
registry, and the token meter. The seeded-history projection baseline asserts
the opposite of what it did — a detached session yields a preset-plane
projection only from a durable checkpoint written while it was live, and this
seed was written straight to persistence and never ran.

Goldens re-recorded for the hero's preset chip and the settings nav entry.
This commit is contained in:
Yichen Jiang
2026-08-06 19:14:13 +08:00
parent b8bc89c2f0
commit d247c50c6f
28 changed files with 225 additions and 127 deletions

View File

@@ -3,6 +3,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import type {} from '@deepseek-ai/dsh-skill'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
async function writeSkill(root: string, name: string): Promise<void> {
@@ -37,10 +39,26 @@ it('isolates replay skill discovery from every ambient host root', async () => {
let scaffold: WebScaffold | undefined
try {
scaffold = await launchWebScaffold()
const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name)
expect(names).not.toContain('ambient-dsh')
expect(names).not.toContain('ambient-agents')
expect(names).not.toContain('ambient-bundled')
const ctx = scaffold.ctx
// The skill registry belongs to one agent's preset, behind an `isolate`
// realm the host cannot resolve by name — so the roots under test are only
// reachable through a composed agent, which is also the only shape that
// ever asks. `serviceFor` is the same addressing the gateway's `skill.list`
// uses for a browser request about a session.
const handle = await ctx.agents.create({
sessionId: SessionId('hermetic-skills'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
const skills = ctx.agentPresets.serviceFor(handle.agent, 'skills')
if (skills === undefined) throw new Error('composed agent mounts no skill registry')
const names = (await skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name)
expect(names).not.toContain('ambient-dsh')
expect(names).not.toContain('ambient-agents')
expect(names).not.toContain('ambient-bundled')
} finally {
await handle.dispose()
}
} finally {
try {
await scaffold?.close()

View File

@@ -249,6 +249,31 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// paths at load, and an in-process boot must NEVER touch the developer's
// real ~/.dsh document or credential file.
const harnessHome = join(workspaceCwd, '.dsh-home')
// Skill discovery is model-visible input, and its roots now resolve inside a
// PRESET — a subtree this lane's include patches cannot reach, because the
// roster mounts it directly per session rather than as a row of the booted
// tree. The row's documented fallback is the environment, so pin that: the
// whole scaffold lifetime, not just the boot, since presets mount when a
// session is created. Without this a developer's real ~/.dsh/skills silently
// enters replay requests and goldens while CI sees none.
const skillRootEnvironment = {
DSH_HOME: join(workspaceCwd, '.dsh-home'),
DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
}
const originalSkillRootEnvironment = Object.fromEntries(
Object.keys(skillRootEnvironment).map(key => [key, process.env[key]]),
)
let skillRootEnvironmentRestored = false
const restoreSkillRootEnvironment = (): void => {
if (skillRootEnvironmentRestored) return
skillRootEnvironmentRestored = true
for (const [key, value] of Object.entries(originalSkillRootEnvironment)) {
if (value === undefined) Reflect.deleteProperty(process.env, key)
else process.env[key] = value
}
}
Object.assign(process.env, skillRootEnvironment)
let persistenceRoot: string
try {
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
@@ -418,6 +443,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
restoreCredentialEnvironment()
restoreSkillRootEnvironment()
if (cleanupFailures.length > 0) {
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
}
@@ -465,6 +491,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
} finally {
restoreCredentialEnvironment()
restoreSkillRootEnvironment()
}
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
},

View File

@@ -18,6 +18,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import { join } from 'node:path'
@@ -170,10 +171,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
const meter = scaffold.ctx.get('tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
const realized = realizeSeedFixture(scaffold, raw, SEED_ID)
await seedSession(scaffold, withCompaction(realized, meter), SEED_ID)
// The meter belongs to an agent's preset, not to the process — token
// accounting is per session. It is used here as a pure pricing function
// over fixture content, so a throwaway composition is enough to reach one.
const priced = await scaffold.ctx.agents.create({
sessionId: SessionId('seeded-history-pricing'),
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
let realizedWithCompaction: string
try {
const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter')
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
} finally {
await priced.dispose()
}
await seedSession(scaffold, realizedWithCompaction, SEED_ID)
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -220,11 +233,17 @@ describe('web e2e: seeded history renders through cold resume', () => {
const projections = body.result.value?.projections
expect(projections).toBeDefined()
expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0)
// The seed carries a session/title event: the title unit must serve it.
// The seed carries a session/title event: the title unit is host-plane, so
// it folds the detached log and serves the value with nothing composed.
expect(typeof projections?.values.title).toBe('string')
// tool-todo is composed but the seed has no todo/write: whole-value null,
// key PRESENT (absence would mean the unit never registered).
expect(projections?.values).toHaveProperty('todos', null)
// `todos` is NOT here, and that is the contract rather than a gap. Its unit
// is registered by `tool-todo` inside an agent's preset, so a detached
// session yields it from exactly one place: a durable checkpoint written
// while the session was live. This seed was written straight to persistence
// and never ran, so it recorded none — and the answer no longer depends on
// whether some UNRELATED session happens to be composed right now, which is
// the whole reason the checkpoint row carries its own view.
expect(projections?.values).not.toHaveProperty('todos')
})
it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {

View File

@@ -11,6 +11,8 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-permission'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
/**
@@ -63,11 +65,26 @@ afterEach(async () => {
it('assembles the shipped Web catalog with the confined access default', async () => {
scaffold = await launchWebScaffold()
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
const ctx = scaffold.ctx
// The catalog belongs to an AGENT, not to the process: every model-facing row
// now lives in a preset mounted under one session's scope, so the global
// layer holds nothing and a caller must name the agent to see anything. This
// composes from the deployment default — what a session that names no preset
// gets — which is the shape this test has always been about.
expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([])
const handle = await ctx.agents.create({
sessionId: SessionId('shipped-composition'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
const names = ctx.tools.schemas(handle.agent).map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
} finally {
await handle.dispose()
}
// `workspace-write` is not "the workspace and nothing else": the shared roots
// helper always admits the temp directories too. Pinning it against an
// explicit mode keeps the claim independent of this surface's default, and

View File

@@ -25,6 +25,10 @@
- img
- text: workspace
- img
- button "标准模式":
- img
- text: 标准模式
- img
- textbox "Describe what you want to build"
- button "Commands":
- img

View File

@@ -25,6 +25,10 @@
- img
- text: workspace
- img
- button "标准模式":
- img
- text: 标准模式
- img
- textbox "Describe what you want to build"
- button "Commands":
- img

View File

@@ -1,6 +1,8 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,6 +7,9 @@
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img

View File

@@ -7,10 +7,17 @@
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- text: Agent 预设 对此后新建的会话生效。运行中的会话保持它开始时的 preset。
- button "standard":
- text: standard
- img
- text: 权限 选择新会话的默认权限模式
- button "Workspace Write":
- text: Workspace Write