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:
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- button "标准模式":
|
||||
- img
|
||||
- text: 标准模式
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- button "标准模式":
|
||||
- img
|
||||
- text: 标准模式
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with the single word" [disabled]
|
||||
- img
|
||||
- text: 标准模式
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user