From bdcf940eea0f2b7a9c58fac22850b630cf0b5beb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 11:20:55 +0800 Subject: [PATCH] fix(agent-presets): re-link recompose through the mount-time binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-merge kept recompose on the removed open re-link. It now moves the binding this roster kept from the agent's mount; an agent that never composed one has nothing to re-link, so the switch is its first bind — exactly a mount — and once bound only the kept binding can move it. --- packages/preset/agent-presets/src/index.ts | 12 ++++++++++-- packages/preset/agent-presets/tests/mount.spec.ts | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 176c6129d6..ba00cdf5e0 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -225,7 +225,10 @@ export class AgentPresets extends Service { * and permanent, so the old composition stays for its other agents and the * new one is ensured BEFORE the link moves. An unknown or unusable preset * therefore throws with the agent exactly as it was — there is no torn-down - * state to restore. + * state to restore. The re-link runs through the binding this roster kept + * from the agent's mount — dsh-scope's only re-link authority. An agent + * that never composed one has nothing to re-link: the switch is then the + * agent's first bind, exactly a mount. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. @@ -238,7 +241,12 @@ export class AgentPresets extends Service { } const preset = await this.resolve(id) const standing = await this.ensureStanding(preset) - setScopeParent(agentKey, standing.key) + const binding = this.bindings.get(agentKey) + if (binding === undefined) { + this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key)) + } else { + binding.rebind(standing.key) + } return preset } diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 308318bd95..47a9794ef8 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -368,9 +368,19 @@ describe('replacing a composition', () => { expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) }) - it('leaves an agent that never composed one with nothing to restore', async () => { + it('binds an agent that never composed one as its first mount', async () => { + // No binding exists to re-link, so the switch is the agent's first bind + // — and once bound, only this roster's kept binding can move it again. const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') }) + await ctx.agentPresets.recompose(handle.agent.ctx, 'standard') + + expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) + }) + + it('leaves an agent that never composed one with nothing to restore', async () => { + const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare-broken') }) + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken')) .rejects.toThrow(/failed to mount/)