diff --git a/src/commands/agents.bind.commands.test.ts b/src/commands/agents.bind.commands.test.ts index fd377c8e8f3..3efb85aec48 100644 --- a/src/commands/agents.bind.commands.test.ts +++ b/src/commands/agents.bind.commands.test.ts @@ -58,6 +58,25 @@ describe("agents bind/unbind commands", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); + it("upgrades existing channel-only binding when accountId is later provided", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + bindings: [{ agentId: "main", match: { channel: "telegram" } }], + }, + }); + + await agentsBindCommand({ bind: ["telegram:work"] }, runtime); + + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "work" } }], + }), + ); + expect(runtime.log).toHaveBeenCalledWith("Updated bindings:"); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + it("unbinds all routes for an agent", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, diff --git a/src/commands/agents.bindings.ts b/src/commands/agents.bindings.ts index 2df9d35a436..c911068ac43 100644 --- a/src/commands/agents.bindings.ts +++ b/src/commands/agents.bindings.ts @@ -8,6 +8,11 @@ import type { ChannelChoice } from "./onboard-types.js"; function bindingMatchKey(match: AgentBinding["match"]) { const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID; + const identityKey = bindingMatchIdentityKey(match); + return [identityKey, accountId].join("|"); +} + +function bindingMatchIdentityKey(match: AgentBinding["match"]) { const roles = Array.isArray(match.roles) ? Array.from( new Set( @@ -20,7 +25,6 @@ function bindingMatchKey(match: AgentBinding["match"]) { : []; return [ match.channel, - accountId, match.peer?.kind ?? "", match.peer?.id ?? "", match.guildId ?? "", @@ -29,6 +33,26 @@ function bindingMatchKey(match: AgentBinding["match"]) { ].join("|"); } +function canUpgradeBindingAccountScope(params: { + existing: AgentBinding; + incoming: AgentBinding; + normalizedIncomingAgentId: string; +}): boolean { + if (!params.incoming.match.accountId?.trim()) { + return false; + } + if (params.existing.match.accountId?.trim()) { + return false; + } + if (normalizeAgentId(params.existing.agentId) !== params.normalizedIncomingAgentId) { + return false; + } + return ( + bindingMatchIdentityKey(params.existing.match) === + bindingMatchIdentityKey(params.incoming.match) + ); +} + export function describeBinding(binding: AgentBinding) { const match = binding.match; const parts = [match.channel]; @@ -53,10 +77,11 @@ export function applyAgentBindings( ): { config: OpenClawConfig; added: AgentBinding[]; + updated: AgentBinding[]; skipped: AgentBinding[]; conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>; } { - const existing = cfg.bindings ?? []; + const existing = [...(cfg.bindings ?? [])]; const existingMatchMap = new Map(); for (const binding of existing) { const key = bindingMatchKey(binding.match); @@ -66,6 +91,7 @@ export function applyAgentBindings( } const added: AgentBinding[] = []; + const updated: AgentBinding[] = []; const skipped: AgentBinding[] = []; const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = []; @@ -81,12 +107,41 @@ export function applyAgentBindings( } continue; } + + const upgradeIndex = existing.findIndex((candidate) => + canUpgradeBindingAccountScope({ + existing: candidate, + incoming: binding, + normalizedIncomingAgentId: agentId, + }), + ); + if (upgradeIndex >= 0) { + const current = existing[upgradeIndex]; + if (!current) { + continue; + } + const previousKey = bindingMatchKey(current.match); + const upgradedBinding: AgentBinding = { + ...current, + agentId, + match: { + ...current.match, + accountId: binding.match.accountId?.trim(), + }, + }; + existing[upgradeIndex] = upgradedBinding; + existingMatchMap.delete(previousKey); + existingMatchMap.set(bindingMatchKey(upgradedBinding.match), agentId); + updated.push(upgradedBinding); + continue; + } + existingMatchMap.set(key, agentId); added.push({ ...binding, agentId }); } - if (added.length === 0) { - return { config: cfg, added, skipped, conflicts }; + if (added.length === 0 && updated.length === 0) { + return { config: cfg, added, updated, skipped, conflicts }; } return { @@ -95,6 +150,7 @@ export function applyAgentBindings( bindings: [...existing, ...added], }, added, + updated, skipped, conflicts, }; diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index 807ecca0b20..61c45392f59 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -125,7 +125,7 @@ export async function agentsAddCommand( const bindingResult = bindingParse.bindings.length > 0 ? applyAgentBindings(nextConfig, bindingParse.bindings) - : { config: nextConfig, added: [], skipped: [], conflicts: [] }; + : { config: nextConfig, added: [], updated: [], skipped: [], conflicts: [] }; await writeConfigFile(bindingResult.config); if (!opts.json) { @@ -145,6 +145,7 @@ export async function agentsAddCommand( model, bindings: { added: bindingResult.added.map(describeBinding), + updated: bindingResult.updated.map(describeBinding), skipped: bindingResult.skipped.map(describeBinding), conflicts: bindingResult.conflicts.map( (conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, diff --git a/src/commands/agents.commands.bind.ts b/src/commands/agents.commands.bind.ts index c48785be598..b7a021053c6 100644 --- a/src/commands/agents.commands.bind.ts +++ b/src/commands/agents.commands.bind.ts @@ -150,7 +150,7 @@ export async function agentsBindCommand( } const result = applyAgentBindings(cfg, parsed.bindings); - if (result.added.length > 0) { + if (result.added.length > 0 || result.updated.length > 0) { await writeConfigFile(result.config); if (!opts.json) { logConfigUpdated(runtime); @@ -160,6 +160,7 @@ export async function agentsBindCommand( const payload = { agentId, added: result.added.map(describeBinding), + updated: result.updated.map(describeBinding), skipped: result.skipped.map(describeBinding), conflicts: result.conflicts.map( (conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, @@ -178,10 +179,17 @@ export async function agentsBindCommand( for (const binding of result.added) { runtime.log(`- ${describeBinding(binding)}`); } - } else { + } else if (result.updated.length === 0) { runtime.log("No new bindings added."); } + if (result.updated.length > 0) { + runtime.log("Updated bindings:"); + for (const binding of result.updated) { + runtime.log(`- ${describeBinding(binding)}`); + } + } + if (result.skipped.length > 0) { runtime.log("Already present:"); for (const binding of result.skipped) { diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts index 48a1e23a3ee..dfb339e4384 100644 --- a/src/commands/agents.test.ts +++ b/src/commands/agents.test.ts @@ -112,6 +112,34 @@ describe("agents helpers", () => { expect(result.config.bindings).toHaveLength(2); }); + it("applyAgentBindings upgrades channel-only binding to account-specific binding for same agent", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "main", + match: { channel: "telegram" }, + }, + ], + }; + + const result = applyAgentBindings(cfg, [ + { + agentId: "main", + match: { channel: "telegram", accountId: "work" }, + }, + ]); + + expect(result.added).toHaveLength(0); + expect(result.updated).toHaveLength(1); + expect(result.conflicts).toHaveLength(0); + expect(result.config.bindings).toEqual([ + { + agentId: "main", + match: { channel: "telegram", accountId: "work" }, + }, + ]); + }); + it("applyAgentBindings treats role-based bindings as distinct routes", () => { const cfg: OpenClawConfig = { bindings: [