Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1751,17 +1751,26 @@ impl AcpClient {
}
"available_commands_update" => {
// Advertised slash commands (ACP slash-commands extension).
// Logged for observability; UI surfacing is a follow-up.
let names: Vec<&str> = update["availableCommands"]
// Forward the complete latest list through the encrypted observer
// stream so Desktop can offer commands for the originating agent.
let commands = update["availableCommands"]
.as_array()
.map(|cmds| cmds.iter().filter_map(|c| c["name"].as_str()).collect())
.cloned()
.unwrap_or_default();
let names: Vec<&str> = commands
.iter()
.filter_map(|command| command["name"].as_str())
.collect();
tracing::info!(
target: "acp::update",
"available_commands_update: {} commands [{}]",
names.len(),
names.join(", ")
);
self.observe(
"available_commands_captured",
serde_json::json!({ "commands": commands }),
);
false
}
"session_info_update" => {
Expand Down Expand Up @@ -3462,6 +3471,43 @@ mod tests {
})
}

#[tokio::test]
async fn available_commands_update_emits_complete_semantic_snapshot() {
let mut client = spawn_inert_client().await;
let observer = ObserverHandle::in_process();
client.set_observer(Some(observer.clone()), 3);

let msg = serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session",
"update": {
"sessionUpdate": "available_commands_update",
"availableCommands": [
{ "name": "review", "description": "Review changes" },
{ "name": "deploy", "description": "Ship it" }
]
}
}
});
let _ = client.handle_session_update(&msg);

let events = observer.snapshot();
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, "available_commands_captured");
assert_eq!(events[0].agent_index, Some(3));
assert_eq!(
events[0].payload,
serde_json::json!({
"commands": [
{ "name": "review", "description": "Review changes" },
{ "name": "deploy", "description": "Ship it" }
]
})
);
}

#[tokio::test]
async fn active_run_id_sets_on_string() {
let mut client = spawn_inert_client().await;
Expand Down
103 changes: 103 additions & 0 deletions desktop/src/features/agents/agentCommandCatalog.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";

import {
getAgentCommandCatalog,
parseAvailableCommandsPayload,
recordAvailableCommandsUpdate,
resetAgentCommandCatalogForTests,
} from "./agentCommandCatalog.ts";

const OWNER = "aa".repeat(32);
const OTHER_OWNER = "bb".repeat(32);
const AGENT = "cc".repeat(32);

function installLocalStorage() {
const values = new Map();
globalThis.window = {
localStorage: {
get length() {
return values.size;
},
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, String(value)),
},
};
}

describe("agent command catalog", () => {
beforeEach(() => {
installLocalStorage();
resetAgentCommandCatalogForTests();
});

it("sanitizes, bounds, and deduplicates advertised commands", () => {
const commands = parseAvailableCommandsPayload({
commands: [
{ name: "/review", description: " Review changes " },
{ name: "REVIEW", description: "duplicate" },
{ name: "bad name" },
{ name: "deploy", description: 42 },
],
});

assert.deepEqual(commands, [
{ name: "review", description: "Review changes" },
{ name: "deploy", description: null },
]);
});

it("keeps the latest complete command list per owner and agent", () => {
assert.equal(
recordAvailableCommandsUpdate(OWNER, AGENT, {
seq: 8,
timestamp: "2026-07-23T08:00:00Z",
payload: { commands: [{ name: "review", description: "Review" }] },
}),
true,
);
assert.equal(
recordAvailableCommandsUpdate(OWNER, AGENT, {
seq: 7,
timestamp: "2026-07-23T07:00:00Z",
payload: { commands: [{ name: "stale" }] },
}),
false,
);

assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT)?.commands, [
{ name: "review", description: "Review" },
]);
assert.equal(getAgentCommandCatalog(OTHER_OWNER).has(AGENT), false);
});

it("treats an empty update as authoritative removal of prior commands", () => {
recordAvailableCommandsUpdate(OWNER, AGENT, {
seq: 1,
timestamp: "2026-07-23T08:00:00Z",
payload: { commands: [{ name: "review" }] },
});
recordAvailableCommandsUpdate(OWNER, AGENT, {
seq: 2,
timestamp: "2026-07-23T08:01:00Z",
payload: { commands: [] },
});

assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT)?.commands, []);
});

it("hydrates a persisted owner-scoped catalog after restart", () => {
recordAvailableCommandsUpdate(OWNER, AGENT, {
seq: 3,
timestamp: "2026-07-23T08:00:00Z",
payload: { commands: [{ name: "review" }] },
});
resetAgentCommandCatalogForTests();

assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT)?.commands, [
{ name: "review", description: null },
]);
});
});
207 changes: 207 additions & 0 deletions desktop/src/features/agents/agentCommandCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { normalizePubkey } from "@/shared/lib/pubkey";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";

const STORAGE_PREFIX = "buzz-agent-command-catalog.v1";
const MAX_COMMANDS_PER_AGENT = 256;
const MAX_COMMAND_NAME_LENGTH = 128;
const MAX_COMMAND_DESCRIPTION_LENGTH = 512;

export type AgentCommand = {
name: string;
description: string | null;
};

export type AgentCommandCatalogEntry = {
commands: readonly AgentCommand[];
seq: number;
timestamp: string;
};

export type AgentCommandCatalog = ReadonlyMap<string, AgentCommandCatalogEntry>;

type PersistedCatalog = {
version: 1;
agents: Record<string, AgentCommandCatalogEntry>;
};

type AvailableCommandsEvent = {
payload: unknown;
seq: number;
timestamp: string;
};

const EMPTY_CATALOG: AgentCommandCatalog = new Map();
// Managed-agent observer ingestion is owner-global; command capabilities belong
// to the agent pubkey rather than whichever community currently renders it.
const catalogByOwner = new Map<string, AgentCommandCatalog>();
const hydratedOwners = new Set<string>();
const listeners = new Set<() => void>();

function storageKey(ownerPubkey: string): string {
return `${STORAGE_PREFIX}:${normalizePubkey(ownerPubkey)}`;
}

function sanitizeCommand(value: unknown): AgentCommand | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (typeof record.name !== "string") return null;

const name = record.name.trim().replace(/^\/+/, "");
if (
name.length === 0 ||
name.length > MAX_COMMAND_NAME_LENGTH ||
/\s|\//u.test(name)
) {
return null;
}

const description =
typeof record.description === "string"
? record.description.trim().slice(0, MAX_COMMAND_DESCRIPTION_LENGTH) ||
null
: null;
return { name, description };
}

export function parseAvailableCommandsPayload(
payload: unknown,
): readonly AgentCommand[] | null {
if (
typeof payload !== "object" ||
payload === null ||
Array.isArray(payload)
) {
return null;
}
const commands = (payload as Record<string, unknown>).commands;
if (!Array.isArray(commands)) return null;

const parsed: AgentCommand[] = [];
const seen = new Set<string>();
for (const value of commands.slice(0, MAX_COMMANDS_PER_AGENT)) {
const command = sanitizeCommand(value);
if (!command) continue;
const key = command.name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
parsed.push(command);
}
return parsed;
}

function parseStoredCatalog(raw: string | null): AgentCommandCatalog {
if (!raw) return EMPTY_CATALOG;
try {
const parsed = JSON.parse(raw) as Partial<PersistedCatalog>;
if (
parsed.version !== 1 ||
!parsed.agents ||
typeof parsed.agents !== "object"
) {
return EMPTY_CATALOG;
}
const next = new Map<string, AgentCommandCatalogEntry>();
for (const [pubkey, entry] of Object.entries(parsed.agents)) {
if (!entry || typeof entry !== "object") continue;
const candidate = entry as Partial<AgentCommandCatalogEntry>;
const commands = parseAvailableCommandsPayload({
commands: candidate.commands,
});
if (
commands === null ||
typeof candidate.seq !== "number" ||
!Number.isSafeInteger(candidate.seq) ||
typeof candidate.timestamp !== "string"
) {
continue;
}
next.set(normalizePubkey(pubkey), {
commands,
seq: candidate.seq,
timestamp: candidate.timestamp,
});
}
return next;
} catch {
return EMPTY_CATALOG;
}
}

function hydrate(ownerPubkey: string): AgentCommandCatalog {
const owner = normalizePubkey(ownerPubkey);
if (!hydratedOwners.has(owner)) {
const raw =
typeof window === "undefined"
? null
: window.localStorage.getItem(storageKey(owner));
catalogByOwner.set(owner, parseStoredCatalog(raw));
hydratedOwners.add(owner);
}
return catalogByOwner.get(owner) ?? EMPTY_CATALOG;
}

function persist(ownerPubkey: string, catalog: AgentCommandCatalog): void {
if (typeof window === "undefined") return;
const agents = Object.fromEntries(catalog.entries());
setLocalStorageItemWithRecovery(
storageKey(ownerPubkey),
JSON.stringify({ version: 1, agents } satisfies PersistedCatalog),
);
}

function isNewer(
incoming: Pick<AgentCommandCatalogEntry, "seq" | "timestamp">,
current: AgentCommandCatalogEntry | undefined,
): boolean {
if (!current) return true;
const incomingTime = Date.parse(incoming.timestamp);
const currentTime = Date.parse(current.timestamp);
if (Number.isFinite(incomingTime) && Number.isFinite(currentTime)) {
if (incomingTime !== currentTime) return incomingTime > currentTime;
}
return incoming.seq > current.seq;
}

export function recordAvailableCommandsUpdate(
ownerPubkey: string,
agentPubkey: string,
event: AvailableCommandsEvent,
): boolean {
const commands = parseAvailableCommandsPayload(event.payload);
if (commands === null || !Number.isSafeInteger(event.seq)) return false;

const owner = normalizePubkey(ownerPubkey);
const agent = normalizePubkey(agentPubkey);
const current = hydrate(owner);
if (!isNewer(event, current.get(agent))) return false;

const next = new Map(current);
next.set(agent, {
commands,
seq: event.seq,
timestamp: event.timestamp,
});
catalogByOwner.set(owner, next);
persist(owner, next);
for (const listener of listeners) listener();
return true;
}

export function getAgentCommandCatalog(
ownerPubkey: string | null,
): AgentCommandCatalog {
return ownerPubkey ? hydrate(ownerPubkey) : EMPTY_CATALOG;
}

export function subscribeAgentCommandCatalog(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

export function resetAgentCommandCatalogForTests(): void {
catalogByOwner.clear();
hydratedOwners.clear();
listeners.clear();
}
Loading