fix(web): address a session's own services from the host
A preset publishes its services behind `isolate` realms, which is what makes them per session — and what makes them invisible to every host context. The api-proxy kept reading the root realm, so requests that are ABOUT a session but arrive from outside it answered for a singleton that no longer exists: `goal.pause`/`clear` and `skill.list` returned "this deployment does not mount @deepseek-ai/dsh-goal / dsh-skill" for sessions whose composition mounts exactly that. Verified against a running host before and after. `agentPresets.serviceFor(agent, name)` addresses the instance instead, reading the same subtree-ownership relation `leakedServices` already uses, inverted. It is read addressing for a caller holding the agent: a host row that `inject`s a service cannot use it, because injection resolves before any session exists — which is why `tools` and `subagents` stay host-plane and this is not a way around that. Tool presenters had the same shape and the same cure: `viewFor` looked definitions up without a scope while the global layer is empty by design, so every card degraded to the generic renderer. It now takes the owning agent. Cold resume through `agentFor()` mounted no preset at all, so every generic entry point — prompt, models, commands — rebuilt a restarted session on host tools and the deployment persona. It composes the recorded preset now, as the other resume path already did.
This commit is contained in:
@@ -414,11 +414,20 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues
|
||||
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
|
||||
* the client's documented default (generic JSON card) covers every miss.
|
||||
*/
|
||||
function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
|
||||
function viewFor(
|
||||
ctx: Context,
|
||||
event: SessionEvent,
|
||||
argsFor: (callId: string) => unknown,
|
||||
// The presenter lives with the definition, and definitions are per agent
|
||||
// now: a preset registers its tools into that agent's layer, leaving the
|
||||
// global layer empty. Looking one up without the owner finds nothing, and
|
||||
// every card silently degrades to the generic renderer.
|
||||
agent?: Agent,
|
||||
): ToolEventView | undefined {
|
||||
try {
|
||||
if (event.type === 'tool/call') {
|
||||
const { name, arguments: raw } = event.data as ToolCallData
|
||||
const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
|
||||
const view = ctx.tools.get(name, agent)?.presentCall?.(JSON.parse(raw))
|
||||
return view === undefined ? undefined : { for: 'call', view }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
@@ -427,7 +436,7 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
|
||||
const callId = message.source.callId
|
||||
const call = argsFor(callId) as { name: string; args: unknown } | undefined
|
||||
if (call === undefined) return undefined
|
||||
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, {
|
||||
const view = ctx.tools.get(call.name, agent)?.presentResult?.(call.args, {
|
||||
content: result.content,
|
||||
isError: result.isError === true,
|
||||
...meta === undefined ? {} : { meta },
|
||||
@@ -470,11 +479,12 @@ function historyPage(
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: number | undefined,
|
||||
maxMessages: number | undefined,
|
||||
agent?: Agent,
|
||||
): { events: HistoryEntry[]; hasMore: boolean } {
|
||||
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||
return {
|
||||
events: page.events.map((event) => {
|
||||
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
||||
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), agent)
|
||||
return { event, ...view === undefined ? {} : { view } }
|
||||
}),
|
||||
hasMore: page.hasMore,
|
||||
@@ -1090,10 +1100,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) {
|
||||
throw new SubagentSessionOwnership(sessionId)
|
||||
}
|
||||
// Cold resume composes the preset the session recorded, for the
|
||||
// same reason `session.create` does: its history was produced under
|
||||
// that composition. Every generic entry point — prompt, models,
|
||||
// commands — arrives here, so leaving it out meant a session opened
|
||||
// after a restart ran on host tools and the deployment persona.
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions,
|
||||
setup: installTarget,
|
||||
setup: (await composeAgent(inspected.meta.agentPreset)).setup,
|
||||
})
|
||||
return handle.agent
|
||||
} finally {
|
||||
@@ -1354,11 +1369,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return items
|
||||
}
|
||||
|
||||
/** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
|
||||
function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
|
||||
const goals = ctx.get('goals')
|
||||
/**
|
||||
* Resolve the goal service THIS agent runs.
|
||||
*
|
||||
* The service is per session: an agent preset mounts it behind an `isolate`
|
||||
* realm, which no host context resolves. Reading it from the root would
|
||||
* answer "absent" for a session whose composition mounts it — so the lookup
|
||||
* is keyed by the agent, and only a deployment composing it nowhere is
|
||||
* genuinely absent.
|
||||
*/
|
||||
function goalServiceFor(agent: Agent): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
|
||||
const presets = ctx.get('agentPresets')
|
||||
const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals')
|
||||
if (goals === undefined) {
|
||||
return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } }
|
||||
return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } }
|
||||
}
|
||||
return goals
|
||||
}
|
||||
@@ -1374,10 +1398,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
request: RpcRequest<{ sessionId: SessionId }>,
|
||||
mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
|
||||
): Promise<RpcResponse<{ ref: GoalRef }>> {
|
||||
const goals = goalService()
|
||||
if ('error' in goals) return err(request, goals.error)
|
||||
const found = await agentFor(request.payload.sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const goals = goalServiceFor(found.agent)
|
||||
if ('error' in goals) return err(request, goals.error)
|
||||
try {
|
||||
const ref = mutation(goals, found.agent)
|
||||
return ok(request, { ref: { id: ref.id, revision: ref.revision } })
|
||||
@@ -1768,7 +1792,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
|
||||
// `ctx.get`, not `ctx.agents`: this is the COLD path, and a caller may
|
||||
// serve history from storage with no agent registry composed at all.
|
||||
// An absent registry means no live agent, which is the same answer a
|
||||
// present one gives here — presenters fall back to the global layer.
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, ctx.get('agents')?.get(sessionId))
|
||||
return ok(request, {
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
@@ -2071,7 +2099,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: { childSessionId },
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
|
||||
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages, ctx.agents.get(childSessionId))
|
||||
const projections = beforeSeq === undefined
|
||||
? detachedProjectionsFor(ctx, snapshot.events)
|
||||
: undefined
|
||||
@@ -2440,10 +2468,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
|
||||
async clear(request) {
|
||||
const goals = goalService()
|
||||
if ('error' in goals) return err(request, goals.error)
|
||||
const found = await agentFor(request.payload.sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const goals = goalServiceFor(found.agent)
|
||||
if ('error' in goals) return err(request, goals.error)
|
||||
try {
|
||||
goals.clear(found.agent, request.payload.ref)
|
||||
return ok(request, { cleared: true as const })
|
||||
@@ -2473,14 +2501,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
|
||||
}
|
||||
const cwd = session.header.cwd
|
||||
// Same stance as the commands domain: a missing service means the
|
||||
// deployment omitted dsh-skill from its composition, not an empty
|
||||
// catalog. ctx.get also keeps this handler independent of the gateway
|
||||
// plugin's inject list (an undeclared `ctx.skills` property read
|
||||
// fails the reflect proxy).
|
||||
const skillRegistry = ctx.get('skills')
|
||||
// The registry is per session when a preset mounts one — a preset
|
||||
// ships its own skill directory, so the catalog IS the session's — and
|
||||
// that instance sits behind an `isolate` realm no host context
|
||||
// resolves. Address it through the live agent; `agents.get` keeps the
|
||||
// no-side-effect stance above (a cold session creates nothing and
|
||||
// falls through to whatever the host composes).
|
||||
const live = ctx.agents.get(sessionId)
|
||||
const presets = ctx.get('agentPresets')
|
||||
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
|
||||
// Same stance as the commands domain: a missing service means no
|
||||
// composition mounts dsh-skill, not an empty catalog. `ctx.get` also
|
||||
// keeps this handler independent of the gateway plugin's inject list
|
||||
// (an undeclared `ctx.skills` property read fails the reflect proxy).
|
||||
const skillRegistry = scoped ?? ctx.get('skills')
|
||||
if (skillRegistry === undefined) {
|
||||
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
|
||||
return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
|
||||
}
|
||||
try {
|
||||
const skills = (await skillRegistry.list({ cwd }))
|
||||
@@ -2711,8 +2747,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
} else if (event.type === 'turn/end') {
|
||||
openCalls.delete(session.id)
|
||||
}
|
||||
const view = viewFor(ctx, event, callId =>
|
||||
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
|
||||
const view = viewFor(
|
||||
ctx, event,
|
||||
callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId),
|
||||
ctx.agents.get(session.id),
|
||||
)
|
||||
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
|
||||
}),
|
||||
ctx.on('session/created', (session: Session) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
||||
import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -44,9 +45,19 @@ function roster(ids: readonly string[]): unknown {
|
||||
},
|
||||
mount: (_ctx: Context, id?: string) =>
|
||||
Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }),
|
||||
// What a real mount leaves behind: a service instance only the agent that
|
||||
// mounted it can be used to address. The doubles are per agent so a test
|
||||
// can tell "this session's" from "some session's".
|
||||
serviceFor: (agent: { id: unknown }, name: string) => {
|
||||
const perAgent = services.get(String(agent.id))
|
||||
return perAgent?.[name]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-agent service instances a mounted preset would own, keyed by session id. */
|
||||
const services = new Map<string, Record<string, unknown>>()
|
||||
|
||||
async function harness(presets?: readonly string[]) {
|
||||
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
|
||||
const ctx = new Context()
|
||||
@@ -143,3 +154,62 @@ describe('session.create with an agent preset', () => {
|
||||
expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A capability a preset mounts is reachable from nowhere the host normally
|
||||
* looks: an `isolate` realm is what makes it per session. The gateway serves
|
||||
* requests that are ABOUT a session from OUTSIDE it, so it addresses the
|
||||
* instance through the agent instead of reading a root-realm singleton.
|
||||
*/
|
||||
describe('a capability the session\'s preset mounts', () => {
|
||||
it('serves the goal RPC from the session\'s own goal service', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' }))
|
||||
const ref = { id: GoalId('goal-1'), revision: 1 }
|
||||
const paused: unknown[] = []
|
||||
services.set('g1', {
|
||||
goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } },
|
||||
})
|
||||
|
||||
const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref }))
|
||||
|
||||
expect(response.result).toMatchObject({ ok: true, value: { ref } })
|
||||
// Reached the instance this session mounted, and was handed its own agent.
|
||||
expect(paused).toEqual([['g1', ref]])
|
||||
services.delete('g1')
|
||||
})
|
||||
|
||||
it('serves the skill catalog from the session\'s own registry', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' }))
|
||||
services.set('k1', {
|
||||
skills: {
|
||||
list: () => Promise.resolve([{
|
||||
name: 'preset-owned',
|
||||
description: 'ships inside the preset directory',
|
||||
invocation: { modelInvocable: true, userInvocable: true },
|
||||
}]),
|
||||
},
|
||||
})
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('k1') }))
|
||||
|
||||
// A preset ships its own skill directory, so the catalog IS the
|
||||
// session's; reading a host singleton would answer for the wrong one.
|
||||
expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } })
|
||||
services.delete('k1')
|
||||
})
|
||||
|
||||
it('says so when no composition mounts the capability at all', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' }))
|
||||
|
||||
const response = await api.skills.list(request({ sessionId: SessionId('n1') }))
|
||||
|
||||
// Absent means absent — not "this session has none", which is what a
|
||||
// root-realm read used to report for every presetd session.
|
||||
expect(response.result.ok).toBe(false)
|
||||
const failure = response.result as { ok: false; error: { message: string } }
|
||||
expect(failure.error.message).toContain('neither this session')
|
||||
})
|
||||
})
|
||||
@@ -112,7 +112,12 @@ describe('subagent gateway', () => {
|
||||
.toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
|
||||
})
|
||||
|
||||
it('reads a healthy direct child without looking up or activating any Agent', async () => {
|
||||
it('reads a healthy direct child without acquiring an Agent owner', async () => {
|
||||
// `bench()` leaves the child with no live Agent at all, so the response
|
||||
// below is produced cold — which is the invariant: the read never creates
|
||||
// or resumes one. It may still CONSULT the live registry, because tool
|
||||
// presenters live with the per-agent definitions and rendering this
|
||||
// child's own cards needs its layer.
|
||||
const { api, getAgent, readSession } = bench()
|
||||
const response = await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
|
||||
@@ -122,7 +127,7 @@ describe('subagent gateway', () => {
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
expect(readSession).toHaveBeenCalledWith(CHILD)
|
||||
expect(getAgent).not.toHaveBeenCalled()
|
||||
expect(getAgent).not.toHaveBeenCalledWith(PARENT)
|
||||
})
|
||||
|
||||
it('reads one-shot history and rejects an address with the wrong mode', async () => {
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { mountPreset } from './mount.ts'
|
||||
import { mountPreset, serviceForAgent } from './mount.ts'
|
||||
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
|
||||
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
||||
export { inactiveRows, leakedServices, livePresetMounts, mountPreset, type PresetMount } from './mount.ts'
|
||||
export {
|
||||
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount,
|
||||
} from './mount.ts'
|
||||
export { PresetMountError, UnknownPresetError } from './types.ts'
|
||||
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts'
|
||||
|
||||
@@ -95,6 +97,25 @@ export class AgentPresets extends Service {
|
||||
await mountPreset(agentCtx, preset)
|
||||
return preset
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent's instance of a service its preset mounted.
|
||||
*
|
||||
* A preset publishes services behind `isolate` realms, which are invisible
|
||||
* outside the group that declares them — including to the host. This is how a
|
||||
* caller holding the agent reads one anyway: a request that is ABOUT a
|
||||
* session but arrives from outside it, which is every browser RPC.
|
||||
*
|
||||
* Read addressing only. A host row that `inject`s a service cannot use this,
|
||||
* because injection resolves before any session exists and has no agent to
|
||||
* key by; such a service belongs on the host plane instead.
|
||||
* @param agent - the agent whose composition to look inside.
|
||||
* @param name - the service name as the preset's rows resolve it.
|
||||
* @returns the agent's instance, or undefined when its preset mounts none.
|
||||
*/
|
||||
serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined {
|
||||
return serviceForAgent(this.ctx, agent, name)
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentPresets
|
||||
@@ -155,6 +155,47 @@ export function leakedServices(ctx: Context, mount: Fiber): string[] {
|
||||
return leaked.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent's instance of a service its preset mounted.
|
||||
*
|
||||
* A preset publishes a service behind an `isolate` realm so two sessions
|
||||
* cannot collide, and an entry-local realm is invisible to everything outside
|
||||
* the group — including the agent's own scope context and the host. That is
|
||||
* right for the rows inside the group and wrong for one caller: a request that
|
||||
* is ABOUT a session but arrives from outside it, which is every browser RPC
|
||||
* the api-proxy serves.
|
||||
*
|
||||
* Ownership is the same relation {@link leakedServices} reads, inverted: there
|
||||
* it names implementations a subtree published into the ROOT realm, here it
|
||||
* names the one this subtree published anywhere. Fiber membership is object
|
||||
* identity for the reason stated on {@link withinFiber}.
|
||||
*
|
||||
* This is READ addressing for a caller that already holds the agent. It is not
|
||||
* a general host handle on a session's internals: a host row that `inject`s a
|
||||
* service cannot use it, because injection resolves before any session exists
|
||||
* and has no agent to key by — such a service belongs on the host plane.
|
||||
* @param ctx - any context of the runtime whose service store is inspected.
|
||||
* @param agent - the agent whose mounted composition to look inside.
|
||||
* @param name - the service name as the preset's rows resolve it.
|
||||
* @returns the agent's instance, or undefined when its preset mounts none.
|
||||
*/
|
||||
export function serviceForAgent<K extends string & keyof Context>(
|
||||
ctx: Context,
|
||||
agent: { ctx: Context },
|
||||
name: K,
|
||||
): Context[K] | undefined {
|
||||
const root = agent.ctx.fiber
|
||||
const store = ctx.reflect.store
|
||||
for (const key of Object.getOwnPropertySymbols(store)) {
|
||||
const impl = store[key]
|
||||
/* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */
|
||||
if (impl === undefined) continue
|
||||
if (impl.name !== name) continue
|
||||
if (withinFiber(impl.fiber, root)) return impl.value as Context[K]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows that did not reach a usable state, each rendered as one diagnostic line.
|
||||
*
|
||||
|
||||
@@ -12,6 +12,13 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Published by the `isolated` fixture preset behind an entry-local realm. */
|
||||
fixtureIsolatedSvc: { label: string }
|
||||
}
|
||||
}
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const ROOTS = [
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
@@ -155,6 +162,29 @@ describe('rejecting a composition that cannot be used', () => {
|
||||
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
|
||||
})
|
||||
|
||||
it('addresses one agent\'s instance of a realm-private service', async () => {
|
||||
const first = await agentOn(ctx, 'sess-reach-a', 'isolated')
|
||||
const second = await agentOn(ctx, 'sess-reach-b', 'isolated')
|
||||
|
||||
// The realm keeps the service out of every host context — that is what
|
||||
// makes it per session — so a caller holding the agent is the only way a
|
||||
// request from OUTSIDE the session can read the instance it is about.
|
||||
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
|
||||
const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc')
|
||||
const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc')
|
||||
expect(mine).toBeDefined()
|
||||
expect(theirs).toBeDefined()
|
||||
// Each agent gets ITS own: the addressing is per subtree, not a lookup
|
||||
// that happens to find the first match.
|
||||
expect(mine).not.toBe(theirs)
|
||||
})
|
||||
|
||||
it('answers undefined for a service the agent\'s preset does not mount', async () => {
|
||||
const agent = await agentOn(ctx, 'sess-reach-none', 'standard')
|
||||
|
||||
expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports the known ids when a preset is unknown', async () => {
|
||||
await expect(ctx.agentPresets.resolve('nope'))
|
||||
.rejects.toThrow(/preset "nope" not found \(available: .*standard/)
|
||||
|
||||
Reference in New Issue
Block a user