fix(web): stop leaking ask_user_question into every preset
`ui-question`'s node half called `ctx.tools.register` on the host context. `ScopedLayers.merge()` combines the global layer with the agent's exact-scope layer, and an unscoped registration lands in the global one — so the tool reached every agent no matter which preset composed it. `core-web`, sold as a two-tool benchmark surface, really presented three. Rendering a question is a host UI capability; having the tool is an agent capability, and only a preset decides that. The node half is now empty and the `tool-ask-user` row moved into the preset that wants it. The TUI keeps its own row, having no presets. The composition tests now assert the global tool layer is EMPTY, which is the invariant that would have caught this: any tool outside a preset reaches every agent. The browser lane's composition, seeded-history, and hermetic-skill assertions address their registries through a composed agent for the same reason — those services are per session now, and the host cannot resolve an `isolate` realm by name.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user