mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: prevent agent proliferation when adding bots to channels (#621)
This commit is contained in:
@@ -62,7 +62,7 @@ const overrides = new Map([
|
||||
["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation
|
||||
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
|
||||
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
|
||||
["src/features/channels/ui/AddChannelBotDialog.tsx", 660], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field
|
||||
["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail
|
||||
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
|
||||
["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
|
||||
["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders
|
||||
|
||||
Generated
+48
-21
@@ -854,6 +854,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -1154,6 +1160,15 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1292,6 +1307,7 @@ dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.1",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2201,6 +2217,15 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -3649,7 +3674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5225,25 +5250,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sprout-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"nostr 0.36.0",
|
||||
"percent-encoding",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sprout-desktop"
|
||||
name = "sprout"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"atomic-write-file",
|
||||
@@ -5298,6 +5305,26 @@ dependencies = [
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sprout-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"nostr 0.36.0",
|
||||
"percent-encoding",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sprout-persona"
|
||||
version = "0.1.0"
|
||||
@@ -8085,7 +8112,7 @@ dependencies = [
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"indexmap 2.14.0",
|
||||
"lzma-rs",
|
||||
"memchr",
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
commandsMatch,
|
||||
parseTimestamp,
|
||||
pickPreferredManagedAgent,
|
||||
findReusablePersonaAgent,
|
||||
findReusableGenericAgent,
|
||||
findReusableAgent,
|
||||
} from "./agentReuse.ts";
|
||||
|
||||
const PUB_A = "a".repeat(64);
|
||||
const PUB_B = "b".repeat(64);
|
||||
const PUB_C = "c".repeat(64);
|
||||
|
||||
function makeAgent(overrides = {}) {
|
||||
return {
|
||||
id: "agent-1",
|
||||
pubkey: PUB_A,
|
||||
agentCommand: "goose",
|
||||
status: "running",
|
||||
personaId: null,
|
||||
systemPrompt: null,
|
||||
updatedAt: "2026-01-15T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- commandsMatch ---
|
||||
|
||||
test("commandsMatch: bare names match", () => {
|
||||
assert.equal(commandsMatch("goose", "goose"), true);
|
||||
});
|
||||
|
||||
test("commandsMatch: path variants match (unix path vs bare)", () => {
|
||||
assert.equal(commandsMatch("/usr/bin/goose", "goose"), true);
|
||||
});
|
||||
|
||||
test("commandsMatch: backslash paths match", () => {
|
||||
assert.equal(commandsMatch("C:\\Users\\bin\\goose", "goose"), true);
|
||||
});
|
||||
|
||||
test("commandsMatch: case insensitive", () => {
|
||||
assert.equal(commandsMatch("Goose", "GOOSE"), true);
|
||||
});
|
||||
|
||||
test("commandsMatch: claude-code-acp normalizes to claude-acp", () => {
|
||||
assert.equal(commandsMatch("claude-code-acp", "claude-acp"), true);
|
||||
});
|
||||
|
||||
test("commandsMatch: claude-agent-acp normalizes to claude-acp", () => {
|
||||
assert.equal(commandsMatch("claude-agent-acp", "claude-acp"), true);
|
||||
});
|
||||
|
||||
test("commandsMatch: claude-code-acp matches claude-agent-acp", () => {
|
||||
assert.equal(commandsMatch("claude-code-acp", "claude-agent-acp"), true);
|
||||
});
|
||||
|
||||
test("commandsMatch: path + claude normalization combined", () => {
|
||||
assert.equal(commandsMatch("/opt/bin/claude-code-acp", "claude-acp"), true);
|
||||
});
|
||||
|
||||
test("commandsMatch: different commands do not match", () => {
|
||||
assert.equal(commandsMatch("goose", "claude-acp"), false);
|
||||
});
|
||||
|
||||
// --- parseTimestamp ---
|
||||
|
||||
test("parseTimestamp: valid ISO string", () => {
|
||||
const result = parseTimestamp("2026-01-15T00:00:00Z");
|
||||
assert.equal(result, Date.parse("2026-01-15T00:00:00Z"));
|
||||
});
|
||||
|
||||
test("parseTimestamp: null returns 0", () => {
|
||||
assert.equal(parseTimestamp(null), 0);
|
||||
});
|
||||
|
||||
test("parseTimestamp: undefined returns 0", () => {
|
||||
assert.equal(parseTimestamp(undefined), 0);
|
||||
});
|
||||
|
||||
test("parseTimestamp: empty string returns 0", () => {
|
||||
assert.equal(parseTimestamp(""), 0);
|
||||
});
|
||||
|
||||
test("parseTimestamp: invalid string returns 0", () => {
|
||||
assert.equal(parseTimestamp("not-a-date"), 0);
|
||||
});
|
||||
|
||||
// --- pickPreferredManagedAgent ---
|
||||
|
||||
test("pickPreferredManagedAgent: empty array returns undefined", () => {
|
||||
assert.equal(pickPreferredManagedAgent([]), undefined);
|
||||
});
|
||||
|
||||
test("pickPreferredManagedAgent: prefers running over stopped", () => {
|
||||
const running = makeAgent({
|
||||
id: "r",
|
||||
status: "running",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
const stopped = makeAgent({
|
||||
id: "s",
|
||||
status: "stopped",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
const result = pickPreferredManagedAgent([stopped, running]);
|
||||
assert.equal(result.id, "r");
|
||||
});
|
||||
|
||||
test("pickPreferredManagedAgent: deployed treated same as running", () => {
|
||||
const deployed = makeAgent({
|
||||
id: "d",
|
||||
status: "deployed",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
const stopped = makeAgent({
|
||||
id: "s",
|
||||
status: "stopped",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
const result = pickPreferredManagedAgent([stopped, deployed]);
|
||||
assert.equal(result.id, "d");
|
||||
});
|
||||
|
||||
test("pickPreferredManagedAgent: among same status, picks most recently updated", () => {
|
||||
const older = makeAgent({
|
||||
id: "old",
|
||||
status: "running",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
const newer = makeAgent({
|
||||
id: "new",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
const result = pickPreferredManagedAgent([older, newer]);
|
||||
assert.equal(result.id, "new");
|
||||
});
|
||||
|
||||
test("pickPreferredManagedAgent: null updatedAt treated as epoch 0", () => {
|
||||
const noTimestamp = makeAgent({
|
||||
id: "no-ts",
|
||||
status: "stopped",
|
||||
updatedAt: null,
|
||||
});
|
||||
const withTimestamp = makeAgent({
|
||||
id: "ts",
|
||||
status: "stopped",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const result = pickPreferredManagedAgent([noTimestamp, withTimestamp]);
|
||||
assert.equal(result.id, "ts");
|
||||
});
|
||||
|
||||
test("pickPreferredManagedAgent: undefined updatedAt treated as epoch 0", () => {
|
||||
const noTimestamp = makeAgent({
|
||||
id: "no-ts",
|
||||
status: "stopped",
|
||||
updatedAt: undefined,
|
||||
});
|
||||
const withTimestamp = makeAgent({
|
||||
id: "ts",
|
||||
status: "stopped",
|
||||
updatedAt: "2025-06-01T00:00:00Z",
|
||||
});
|
||||
const result = pickPreferredManagedAgent([noTimestamp, withTimestamp]);
|
||||
assert.equal(result.id, "ts");
|
||||
});
|
||||
|
||||
// --- findReusablePersonaAgent ---
|
||||
|
||||
test("findReusablePersonaAgent: finds agent with matching personaId", () => {
|
||||
const agent = makeAgent({ personaId: "persona-1", pubkey: PUB_A });
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusablePersonaAgent([agent], "persona-1", channelMembers);
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
test("findReusablePersonaAgent: excludes agent already in channel", () => {
|
||||
const agent = makeAgent({ personaId: "persona-1", pubkey: PUB_A });
|
||||
const channelMembers = new Set([PUB_A]);
|
||||
const result = findReusablePersonaAgent([agent], "persona-1", channelMembers);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("findReusablePersonaAgent: excludes agent with different personaId", () => {
|
||||
const agent = makeAgent({ personaId: "persona-2", pubkey: PUB_A });
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusablePersonaAgent([agent], "persona-1", channelMembers);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("findReusablePersonaAgent: prefers running agent", () => {
|
||||
const stopped = makeAgent({
|
||||
id: "s",
|
||||
personaId: "p1",
|
||||
pubkey: PUB_A,
|
||||
status: "stopped",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
const running = makeAgent({
|
||||
id: "r",
|
||||
personaId: "p1",
|
||||
pubkey: PUB_B,
|
||||
status: "running",
|
||||
updatedAt: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
const channelMembers = new Set([PUB_C]);
|
||||
const result = findReusablePersonaAgent(
|
||||
[stopped, running],
|
||||
"p1",
|
||||
channelMembers,
|
||||
);
|
||||
assert.equal(result.id, "r");
|
||||
});
|
||||
|
||||
test("findReusablePersonaAgent: pubkey comparison is case-insensitive", () => {
|
||||
const agent = makeAgent({ personaId: "p1", pubkey: PUB_A.toUpperCase() });
|
||||
const channelMembers = new Set([PUB_A]);
|
||||
const result = findReusablePersonaAgent([agent], "p1", channelMembers);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
// --- findReusableGenericAgent ---
|
||||
|
||||
test("findReusableGenericAgent: finds agent with matching command and no persona/prompt", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: null,
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableGenericAgent([agent], "goose", channelMembers);
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
test("findReusableGenericAgent: excludes agent with personaId", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: "some-persona",
|
||||
systemPrompt: null,
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableGenericAgent([agent], "goose", channelMembers);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("findReusableGenericAgent: excludes agent with non-empty systemPrompt", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: "Do stuff",
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableGenericAgent([agent], "goose", channelMembers);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("findReusableGenericAgent: whitespace-only systemPrompt treated as empty (allowed)", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: " \t\n ",
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableGenericAgent([agent], "goose", channelMembers);
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
test("findReusableGenericAgent: undefined systemPrompt treated as empty (allowed)", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: undefined,
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableGenericAgent([agent], "goose", channelMembers);
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
test("findReusableGenericAgent: empty string systemPrompt treated as empty (allowed)", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: "",
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableGenericAgent([agent], "goose", channelMembers);
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
test("findReusableGenericAgent: excludes agent already in channel", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: null,
|
||||
pubkey: PUB_A,
|
||||
});
|
||||
const channelMembers = new Set([PUB_A]);
|
||||
const result = findReusableGenericAgent([agent], "goose", channelMembers);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("findReusableGenericAgent: command matching uses normalization", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "/usr/local/bin/claude-code-acp",
|
||||
personaId: null,
|
||||
systemPrompt: null,
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableGenericAgent(
|
||||
[agent],
|
||||
"claude-acp",
|
||||
channelMembers,
|
||||
);
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
// --- findReusableAgent (unified entry point) ---
|
||||
|
||||
test("findReusableAgent: routes to persona search when personaId provided", () => {
|
||||
const agent = makeAgent({ personaId: "p1", pubkey: PUB_A });
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableAgent([agent], channelMembers, {
|
||||
personaId: "p1",
|
||||
command: "goose",
|
||||
});
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
test("findReusableAgent: routes to generic search when no personaId and no prompt", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: null,
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableAgent([agent], channelMembers, {
|
||||
command: "goose",
|
||||
});
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
test("findReusableAgent: returns undefined when systemPrompt is non-empty (custom agent)", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: null,
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableAgent([agent], channelMembers, {
|
||||
command: "goose",
|
||||
systemPrompt: "Custom instructions",
|
||||
});
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("findReusableAgent: whitespace-only systemPrompt in input still routes to generic", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: null,
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableAgent([agent], channelMembers, {
|
||||
command: "goose",
|
||||
systemPrompt: " ",
|
||||
});
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
test("findReusableAgent: null personaId in input routes to generic", () => {
|
||||
const agent = makeAgent({
|
||||
agentCommand: "goose",
|
||||
personaId: null,
|
||||
systemPrompt: null,
|
||||
});
|
||||
const channelMembers = new Set([PUB_B]);
|
||||
const result = findReusableAgent([agent], channelMembers, {
|
||||
personaId: null,
|
||||
command: "goose",
|
||||
});
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
/** Inline normalization — avoids runtime dependency on @/shared/lib/pubkey. */
|
||||
function normalizePubkey(pubkey: string): string {
|
||||
return pubkey.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function commandBasename(command: string) {
|
||||
const normalized = command.trim().replace(/\\/g, "/");
|
||||
const parts = normalized.split("/");
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function normalizeCommandIdentity(command: string) {
|
||||
const lower = commandBasename(command).toLowerCase();
|
||||
if (lower === "claude-code-acp" || lower === "claude-agent-acp") {
|
||||
return "claude-acp";
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
export function commandsMatch(left: string, right: string) {
|
||||
return normalizeCommandIdentity(left) === normalizeCommandIdentity(right);
|
||||
}
|
||||
|
||||
export function parseTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
export function pickPreferredManagedAgent(agents: ManagedAgent[]) {
|
||||
return [...agents].sort((left, right) => {
|
||||
const leftRunningScore =
|
||||
left.status === "running" || left.status === "deployed" ? 1 : 0;
|
||||
const rightRunningScore =
|
||||
right.status === "running" || right.status === "deployed" ? 1 : 0;
|
||||
if (leftRunningScore !== rightRunningScore) {
|
||||
return rightRunningScore - leftRunningScore;
|
||||
}
|
||||
|
||||
return parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt);
|
||||
})[0];
|
||||
}
|
||||
|
||||
export function findReusablePersonaAgent(
|
||||
agents: ManagedAgent[],
|
||||
personaId: string,
|
||||
channelMemberPubkeys: ReadonlySet<string>,
|
||||
): ManagedAgent | undefined {
|
||||
const candidates = agents.filter(
|
||||
(agent) =>
|
||||
agent.personaId === personaId &&
|
||||
!channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
|
||||
);
|
||||
return pickPreferredManagedAgent(candidates);
|
||||
}
|
||||
|
||||
export function findReusableGenericAgent(
|
||||
agents: ManagedAgent[],
|
||||
command: string,
|
||||
channelMemberPubkeys: ReadonlySet<string>,
|
||||
): ManagedAgent | undefined {
|
||||
const candidates = agents.filter(
|
||||
(agent) =>
|
||||
!agent.personaId &&
|
||||
!agent.systemPrompt?.trim() &&
|
||||
commandsMatch(agent.agentCommand, command) &&
|
||||
!channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
|
||||
);
|
||||
return pickPreferredManagedAgent(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a reusable agent exists for the given input. Used by the UI to
|
||||
* surface the "reuse vs create new" guardrail before submission.
|
||||
*/
|
||||
export function findReusableAgent(
|
||||
agents: ManagedAgent[],
|
||||
channelMemberPubkeys: ReadonlySet<string>,
|
||||
input: {
|
||||
personaId?: string | null;
|
||||
systemPrompt?: string;
|
||||
command: string;
|
||||
},
|
||||
): ManagedAgent | undefined {
|
||||
if (input.personaId) {
|
||||
return findReusablePersonaAgent(
|
||||
agents,
|
||||
input.personaId,
|
||||
channelMemberPubkeys,
|
||||
);
|
||||
}
|
||||
if (!input.systemPrompt?.trim()) {
|
||||
return findReusableGenericAgent(
|
||||
agents,
|
||||
input.command,
|
||||
channelMemberPubkeys,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
commandsMatch,
|
||||
findReusableGenericAgent,
|
||||
findReusablePersonaAgent,
|
||||
pickPreferredManagedAgent,
|
||||
} from "@/features/agents/agentReuse";
|
||||
export { findReusableAgent } from "@/features/agents/agentReuse";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import {
|
||||
addChannelMembers,
|
||||
@@ -62,6 +69,8 @@ export type CreateChannelManagedAgentInput = {
|
||||
respondTo?: RespondToMode;
|
||||
/** Hex pubkeys for allowlist mode. */
|
||||
respondToAllowlist?: string[];
|
||||
/** Skip reuse logic and always create a fresh agent instance. */
|
||||
forceNewInstance?: boolean;
|
||||
};
|
||||
|
||||
export type CreateChannelManagedAgentResult =
|
||||
@@ -82,33 +91,6 @@ export type CreateChannelManagedAgentsResult = {
|
||||
failures: CreateChannelManagedAgentBatchFailure[];
|
||||
};
|
||||
|
||||
function commandBasename(command: string) {
|
||||
const normalized = command.trim().replace(/\\/g, "/");
|
||||
const parts = normalized.split("/");
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function normalizeCommandIdentity(command: string) {
|
||||
const lower = commandBasename(command).toLowerCase();
|
||||
if (lower === "claude-code-acp" || lower === "claude-agent-acp") {
|
||||
return "claude-acp";
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
function commandsMatch(left: string, right: string) {
|
||||
return normalizeCommandIdentity(left) === normalizeCommandIdentity(right);
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
export async function attachManagedAgentToChannel(
|
||||
channelId: string,
|
||||
input: AttachManagedAgentToChannelInput,
|
||||
@@ -165,33 +147,6 @@ export async function attachManagedAgentToChannel(
|
||||
} satisfies AttachManagedAgentToChannelResult;
|
||||
}
|
||||
|
||||
function pickPreferredManagedAgent(agents: ManagedAgent[]) {
|
||||
return [...agents].sort((left, right) => {
|
||||
const leftRunningScore =
|
||||
left.status === "running" || left.status === "deployed" ? 1 : 0;
|
||||
const rightRunningScore =
|
||||
right.status === "running" || right.status === "deployed" ? 1 : 0;
|
||||
if (leftRunningScore !== rightRunningScore) {
|
||||
return rightRunningScore - leftRunningScore;
|
||||
}
|
||||
|
||||
return parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt);
|
||||
})[0];
|
||||
}
|
||||
|
||||
function findReusablePersonaAgent(
|
||||
agents: ManagedAgent[],
|
||||
personaId: string,
|
||||
channelMemberPubkeys: ReadonlySet<string>,
|
||||
): ManagedAgent | undefined {
|
||||
const candidates = agents.filter(
|
||||
(agent) =>
|
||||
agent.personaId === personaId &&
|
||||
!channelMemberPubkeys.has(normalizePubkey(agent.pubkey)),
|
||||
);
|
||||
return pickPreferredManagedAgent(candidates);
|
||||
}
|
||||
|
||||
function buildChannelAgentName(providerId: string, providerLabel: string) {
|
||||
const normalizedProviderId = providerId.trim().toLowerCase();
|
||||
if (normalizedProviderId.length > 0) {
|
||||
@@ -303,6 +258,7 @@ export async function createChannelManagedAgent(
|
||||
// and is not already in this channel, attach it instead of creating a new one.
|
||||
if (
|
||||
input.personaId &&
|
||||
!input.forceNewInstance &&
|
||||
context?.managedAgents &&
|
||||
context.channelMemberPubkeys
|
||||
) {
|
||||
@@ -342,6 +298,49 @@ export async function createChannelManagedAgent(
|
||||
}
|
||||
}
|
||||
|
||||
// Generic agent reuse: if no persona is set and the system prompt is blank,
|
||||
// look for an existing agent with the same command and no custom prompt.
|
||||
if (
|
||||
!input.personaId &&
|
||||
!input.systemPrompt?.trim() &&
|
||||
!input.forceNewInstance &&
|
||||
context?.managedAgents &&
|
||||
context.channelMemberPubkeys
|
||||
) {
|
||||
const reusable = findReusableGenericAgent(
|
||||
context.managedAgents,
|
||||
input.provider.command,
|
||||
context.channelMemberPubkeys,
|
||||
);
|
||||
if (reusable) {
|
||||
const needsRespondToUpdate =
|
||||
input.respondTo && input.respondTo !== "owner-only";
|
||||
const updatedAgent = needsRespondToUpdate
|
||||
? (
|
||||
await updateManagedAgent({
|
||||
pubkey: reusable.pubkey,
|
||||
respondTo: input.respondTo,
|
||||
respondToAllowlist:
|
||||
input.respondTo === "allowlist"
|
||||
? input.respondToAllowlist
|
||||
: undefined,
|
||||
})
|
||||
).agent
|
||||
: reusable;
|
||||
|
||||
const attached = await attachManagedAgentToChannel(channelId, {
|
||||
agent: updatedAgent,
|
||||
role,
|
||||
ensureRunning,
|
||||
});
|
||||
return {
|
||||
...attached,
|
||||
created: false,
|
||||
providerId: input.provider.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If the avatar is a data URI (e.g. from a persona PNG card import),
|
||||
// upload it to get a hosted URL the relay can serve.
|
||||
let resolvedAvatarUrl = input.avatarUrl?.trim() || undefined;
|
||||
|
||||
@@ -55,6 +55,7 @@ import type {
|
||||
EnsureChannelAgentPresetInput,
|
||||
EnsureChannelAgentPresetResult,
|
||||
} from "@/features/agents/channelAgents";
|
||||
export { findReusableAgent } from "@/features/agents/agentReuse";
|
||||
export type {
|
||||
AttachManagedAgentToChannelInput,
|
||||
AttachManagedAgentToChannelResult,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { useInChannelPersonaIds } from "@/features/channels/ui/useInChannelPersonaIds";
|
||||
import { AddChannelBotGenericSection } from "@/features/channels/ui/AddChannelBotGenericSection";
|
||||
import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection";
|
||||
import { AddChannelBotReuseGuard } from "@/features/channels/ui/AddChannelBotReuseGuard";
|
||||
import { useReusableAgentDetection } from "@/features/channels/ui/useReusableAgentDetection";
|
||||
import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection";
|
||||
import { probeBackendProvider } from "@/shared/api/tauri";
|
||||
import type {
|
||||
@@ -135,6 +137,7 @@ export function AddChannelBotDialog({
|
||||
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [forceNewInstance, setForceNewInstance] = React.useState(false);
|
||||
|
||||
const resolvedBackendProviders = backendProviders ?? [];
|
||||
const resolvedBackendProvidersLoading = backendProvidersLoading ?? false;
|
||||
@@ -160,6 +163,15 @@ export function AddChannelBotDialog({
|
||||
);
|
||||
const selectedCount = selectedPersonas.length + (includeGeneric ? 1 : 0);
|
||||
|
||||
const reusableAgent = useReusableAgentDetection(
|
||||
channelId,
|
||||
open && channelId !== null,
|
||||
selectedProvider,
|
||||
selectedPersonas,
|
||||
includeGeneric,
|
||||
customPrompt,
|
||||
);
|
||||
|
||||
// Surface warnings when a persona's preferred provider differs from the
|
||||
// user-selected provider. In this dialog the user explicitly picks a
|
||||
// runtime via the dropdown, so the fallback is `selectedProvider` (their
|
||||
@@ -272,6 +284,7 @@ export function AddChannelBotDialog({
|
||||
setProbeError(null);
|
||||
setRespondTo("owner-only");
|
||||
setRespondToAllowlist([]);
|
||||
setForceNewInstance(false);
|
||||
createBotsMutation.reset();
|
||||
}
|
||||
|
||||
@@ -339,6 +352,7 @@ export function AddChannelBotDialog({
|
||||
systemPrompt: customPrompt,
|
||||
role: "bot" as const,
|
||||
backend,
|
||||
forceNewInstance,
|
||||
...respondToFields,
|
||||
},
|
||||
]
|
||||
@@ -358,6 +372,7 @@ export function AddChannelBotDialog({
|
||||
model: persona.model ?? undefined,
|
||||
role: "bot" as const,
|
||||
backend,
|
||||
forceNewInstance,
|
||||
...respondToFields,
|
||||
};
|
||||
}),
|
||||
@@ -594,6 +609,17 @@ export function AddChannelBotDialog({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{reusableAgent ? (
|
||||
<div className="pt-2">
|
||||
<AddChannelBotReuseGuard
|
||||
disabled={createBotsMutation.isPending}
|
||||
forceNew={forceNewInstance}
|
||||
onForceNewChange={setForceNewInstance}
|
||||
reusableAgent={reusableAgent}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedCount > 0 ? (
|
||||
<CreateAgentRespondToField
|
||||
allowlist={respondToAllowlist}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
type AddChannelBotReuseGuardProps = {
|
||||
reusableAgent: ManagedAgent;
|
||||
forceNew: boolean;
|
||||
onForceNewChange: (forceNew: boolean) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export function AddChannelBotReuseGuard({
|
||||
reusableAgent,
|
||||
forceNew,
|
||||
onForceNewChange,
|
||||
disabled,
|
||||
}: AddChannelBotReuseGuardProps) {
|
||||
const statusLabel =
|
||||
reusableAgent.status === "running" || reusableAgent.status === "deployed"
|
||||
? "running"
|
||||
: "stopped";
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="agent-instance-mode">
|
||||
<label className="text-sm font-medium" htmlFor="agent-instance-mode">
|
||||
Agent instance
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
disabled={disabled}
|
||||
id="agent-instance-mode"
|
||||
onChange={(e) => onForceNewChange(e.target.value === "new")}
|
||||
value={forceNew ? "new" : "reuse"}
|
||||
>
|
||||
<option value="reuse">Reuse existing agent</option>
|
||||
<option value="new">Create new instance</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{reusableAgent.name}
|
||||
</span>{" "}
|
||||
is already {statusLabel}. Reusing adds it to this channel without
|
||||
creating a duplicate keypair.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
findReusableAgent,
|
||||
useManagedAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useChannelMembersQuery } from "@/features/channels/hooks";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import type { AcpProvider, ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
type Persona = { id: string };
|
||||
|
||||
/**
|
||||
* Detects whether a reusable managed agent exists for the current dialog
|
||||
* selection. Returns the reusable agent (if any) so the UI can show the
|
||||
* "reuse vs create new" guardrail.
|
||||
*/
|
||||
export function useReusableAgentDetection(
|
||||
channelId: string | null,
|
||||
enabled: boolean,
|
||||
selectedProvider: AcpProvider | null,
|
||||
selectedPersonas: readonly Persona[],
|
||||
includeGeneric: boolean,
|
||||
customPrompt: string,
|
||||
): ManagedAgent | undefined {
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const channelMembersQuery = useChannelMembersQuery(channelId, enabled);
|
||||
|
||||
return React.useMemo(() => {
|
||||
const agents = managedAgentsQuery.data;
|
||||
const members = channelMembersQuery.data;
|
||||
if (!agents || !members || !selectedProvider) return undefined;
|
||||
const memberPubkeys = new Set(
|
||||
members.map((m) => normalizePubkey(m.pubkey)),
|
||||
);
|
||||
|
||||
// For persona selection: check the first selected persona
|
||||
if (selectedPersonas.length === 1 && !includeGeneric) {
|
||||
return findReusableAgent(agents, memberPubkeys, {
|
||||
personaId: selectedPersonas[0].id,
|
||||
command: selectedProvider.command,
|
||||
});
|
||||
}
|
||||
|
||||
// For generic agent with no custom prompt
|
||||
if (
|
||||
includeGeneric &&
|
||||
selectedPersonas.length === 0 &&
|
||||
!customPrompt.trim()
|
||||
) {
|
||||
return findReusableAgent(agents, memberPubkeys, {
|
||||
command: selectedProvider.command,
|
||||
systemPrompt: customPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
managedAgentsQuery.data,
|
||||
channelMembersQuery.data,
|
||||
selectedProvider,
|
||||
selectedPersonas,
|
||||
includeGeneric,
|
||||
customPrompt,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user