Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

recompose becomes a parent re-link: the new preset's standing mount is
ensured BEFORE the link moves, so a failed switch leaves the agent exactly as
it was — the unmount-then-restore dance (and unmountPresetFor with it) is
gone, and the restore-failure test now asserts the agent KEEPS its tools with
the source directory deleted, because the standing mount is not the file.
This commit is contained in:
Yichen Jiang
2026-08-08 18:52:45 +08:00
24 changed files with 600 additions and 185 deletions
@@ -38,7 +38,7 @@ async function harness(): Promise<Context> {
}
describe('agent-presets invariants', () => {
it('tracks a mounted composition and forgets it once the agent is gone', async () => {
it('keeps the standing composition alive across the agents that joined it', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
sessionId: SessionId('inv-live'),
@@ -47,8 +47,20 @@ describe('agent-presets invariants', () => {
expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard')
// A standing mount survives its agents: the composition a session joined
// is shared, so one session ending must not strip it from the next.
await handle.dispose()
expect(livePresetMounts().map(mount => mount.presetId)).toContain('standard')
// A second agent reuses the same mount rather than adding one.
await ctx.agents.create({
sessionId: SessionId('inv-live-2'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(1)
// Whole-tree teardown is the boundary that does reclaim it.
await ctx.fiber.dispose()
expect(livePresetMounts().map(mount => mount.presetId)).not.toContain('standard')
})
@@ -164,21 +164,21 @@ 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 () => {
it('addresses the standing instance of a realm-private service through either agent', 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.
// The realm keeps the service out of every host context, so a caller
// holding the agent is how a request from OUTSIDE the session reads 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)
// ONE composition per preset: both agents joined the same standing mount,
// so they address the same instance — sessions stay apart inside it by
// the plugin's own Session/Agent keying, not by instance count.
expect(theirs).toBe(mine)
})
it('answers undefined for a service the agent\'s preset does not mount', async () => {
@@ -342,7 +342,7 @@ describe('replacing a composition', () => {
expect(toolNames(ctx, handle.agent)).toEqual([])
})
it('reports the switch failure when the restore fails too', async () => {
it('keeps the agent on its standing composition when a switch fails, even with the source deleted', async () => {
// A preset root this test owns, so removing the composition mid-flight
// cannot disturb the shipped fixtures.
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-'))
@@ -368,14 +368,17 @@ describe('replacing a composition', () => {
})
// The roster is a live directory: the composition the agent came from can
// be gone by the time the restore reaches for it.
// be gone from DISK by the time a switch fails. The standing mount is not
// the file — it outlives deletion, so there is nothing to "restore".
await rm(join(root, 'first'), { recursive: true })
await expect(scoped.agentPresets.recompose(handle.agent.ctx, 'broken'))
.rejects.toThrow(/failed to mount/)
// The switch failure is the actionable one; the restore's is swallowed.
expect(toolNames(scoped, handle.agent)).toEqual([])
// The failed switch left the agent EXACTLY as it was: the new standing
// mount is ensured before the parent link moves, so a rejection never
// strips the old composition.
expect(toolNames(scoped, handle.agent)).toEqual(['only'])
})
it('refuses an unscoped context', async () => {