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:
Yichen Jiang
2026-08-06 13:39:40 +08:00
parent fedb8a2702
commit c58cc23d45
6 changed files with 234 additions and 28 deletions
@@ -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 () => {