Chat-first agent creation via portable buzz CLI drafts (#1878)

This commit is contained in:
morgmart
2026-07-15 09:24:57 -07:00
committed by GitHub
parent 55a46b063e
commit 54e174b5e1
43 changed files with 2271 additions and 754 deletions
Generated
+2
View File
@@ -887,6 +887,7 @@ dependencies = [
"buzz-persona",
"buzz-sdk",
"buzz-ws-client",
"chrono",
"clap",
"diffy",
"hex",
@@ -965,6 +966,7 @@ dependencies = [
"nostr",
"reqwest 0.13.4",
"rmcp",
"rustls",
"schemars",
"serde",
"serde_json",
+12 -1
View File
@@ -6,6 +6,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| Group | Key commands |
|-------|-------------|
| `buzz agents` | `draft-create`, `draft-update` |
| `buzz messages` | `send`, `get`, `thread`, `search` |
| `buzz channels` | `list`, `get`, `create`, `join`, `members` |
| `buzz canvas` | `get`, `set` |
@@ -18,7 +19,17 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| `buzz repos` | `create`, `get`, `list` |
| `buzz upload` | `file` |
Run `buzz --help` or `buzz <group> --help` for full usage.
Run `buzz --help` or `buzz <group> --help` for full usage. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat.
## Conversational Agent Creation
When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous.
`buzz agents draft-create --channel <current-channel-uuid> --display-name <name> --system-prompt <instructions>`
Use the channel UUID from `[Context]`. Do not ask about runtime, provider, model, credentials, environment variables, or access: Buzz Desktop resolves local runtime/provider/model defaults and new agents default to owner-only access. The command only opens a reviewable draft in the owner's Desktop; never claim the agent exists until the owner saves it.
For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. Draft updates also require owner review and save.
## Communication Patterns
+13
View File
@@ -3144,6 +3144,19 @@ fn dispatch_heartbeat(
tracing::info!(agent = agent_index, "heartbeat_fired");
}
#[cfg(test)]
mod agent_draft_prompt_tests {
#[test]
fn shared_base_prompt_teaches_portable_agent_drafts() {
let prompt = include_str!("base_prompt.md");
assert!(prompt.contains("buzz agents draft-create"));
assert!(prompt.contains("ask for at most two things"));
assert!(prompt.contains("what it should do day-to-day"));
assert!(prompt.contains("owner saves it"));
assert!(prompt.contains("Do not ask about runtime, provider, model, credentials"));
}
}
fn default_heartbeat_prompt() -> String {
let now = chrono::Utc::now().to_rfc3339();
format!(
+9 -1
View File
@@ -54,10 +54,13 @@ const PASSTHROUGH_ENV: &[&str] = &[
"GIT_CONFIG_GLOBAL",
// Buzz identity — dev-mcp writes NOSTR_PRIVATE_KEY to a keyfile then
// removes it from its own env (children never see it). BUZZ_PRIVATE_KEY
// and BUZZ_RELAY_URL are kept for the buzz CLI.
// and BUZZ_RELAY_URL are kept for the buzz CLI. BUZZ_AUTH_TAG is a
// non-secret signed ownership attestation needed by portable owner-scoped
// CLI operations; MCP subprocesses are trusted like the agent runtime.
"NOSTR_PRIVATE_KEY",
"BUZZ_PRIVATE_KEY",
"BUZZ_RELAY_URL",
"BUZZ_AUTH_TAG",
];
// Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what
@@ -987,6 +990,11 @@ fn tool_result_content(
#[cfg(test)]
mod content_tests {
use super::*;
#[test]
fn passthrough_includes_buzz_owner_attestation() {
assert!(PASSTHROUGH_ENV.contains(&"BUZZ_AUTH_TAG"));
}
use rmcp::model::Content;
#[cfg(windows)]
+3
View File
@@ -38,6 +38,9 @@ nostr = { workspace = true }
# UUID parsing for validate_uuid + event building
uuid = { workspace = true }
# RFC3339 observer timestamps for owner-reviewed agent draft requests
chrono = { workspace = true }
# Typed event builders for all write operations
buzz-sdk = { workspace = true }
buzz-core = { workspace = true }
+277
View File
@@ -0,0 +1,277 @@
//! Owner-reviewed agent draft requests published through Buzz observer frames.
use buzz_core::observer::{encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY};
use nostr::{Event, Keys, PublicKey};
use serde::Serialize;
use crate::error::CliError;
const REQUEST_KIND: &str = "agent_management_request";
const MAX_NAME_CHARS: usize = 120;
const MAX_PROMPT_CHARS: usize = 20_000;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAgentDraft {
pub channel_id: String,
pub display_name: String,
pub system_prompt: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentDraft {
pub channel_id: String,
pub agent_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub respond_to: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ManagementRequest<T> {
#[serde(rename = "type")]
request_type: &'static str,
action: &'static str,
request_id: String,
request: T,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ObserverEvent<T> {
seq: u64,
timestamp: String,
kind: &'static str,
agent_index: Option<usize>,
channel_id: Option<String>,
session_id: Option<String>,
turn_id: Option<String>,
payload: ManagementRequest<T>,
}
#[derive(Debug)]
pub struct BuiltDraftRequest {
pub event: Event,
pub request_id: String,
pub action: &'static str,
}
fn required(value: String, label: &str, max: usize) -> Result<String, CliError> {
let value = value.trim();
if value.is_empty() {
return Err(CliError::Usage(format!("{label} is required")));
}
if value.chars().count() > max {
return Err(CliError::Usage(format!(
"{label} is too long (max {max} characters)"
)));
}
Ok(value.to_owned())
}
fn optional(value: Option<String>, label: &str) -> Result<Option<String>, CliError> {
value.map(|value| required(value, label, 300)).transpose()
}
fn build<T: Serialize>(
keys: &Keys,
owner: &PublicKey,
channel_id: String,
action: &'static str,
request: T,
) -> Result<BuiltDraftRequest, CliError> {
let request_id = uuid::Uuid::new_v4().to_string();
let payload = ObserverEvent {
seq: 0,
timestamp: chrono::Utc::now().to_rfc3339(),
kind: REQUEST_KIND,
agent_index: None,
channel_id: Some(channel_id),
session_id: None,
turn_id: None,
payload: ManagementRequest {
request_type: REQUEST_KIND,
action,
request_id: request_id.clone(),
request,
},
};
let encrypted = encrypt_observer_payload(keys, owner, &payload)
.map_err(|error| CliError::Other(format!("could not encrypt draft request: {error}")))?;
let event = buzz_sdk::build_agent_observer_frame(
&owner.to_hex(),
&keys.public_key().to_hex(),
OBSERVER_FRAME_TELEMETRY,
&encrypted,
)
.map_err(|error| CliError::Other(format!("could not build draft request: {error}")))?
.sign_with_keys(keys)
.map_err(|error| CliError::Other(format!("could not sign draft request: {error}")))?;
Ok(BuiltDraftRequest {
event,
request_id,
action,
})
}
pub fn build_create(
keys: &Keys,
owner: &PublicKey,
draft: CreateAgentDraft,
) -> Result<BuiltDraftRequest, CliError> {
let channel_id = required(draft.channel_id, "channel", 128)?;
uuid::Uuid::parse_str(&channel_id)
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?;
let request = CreateAgentDraft {
channel_id: channel_id.clone(),
display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?,
system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?,
};
build(keys, owner, channel_id, "create", request)
}
pub fn build_update(
keys: &Keys,
owner: &PublicKey,
draft: UpdateAgentDraft,
) -> Result<BuiltDraftRequest, CliError> {
let channel_id = required(draft.channel_id, "channel", 128)?;
uuid::Uuid::parse_str(&channel_id)
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?;
let respond_to = optional(draft.respond_to, "respond-to")?;
if respond_to
.as_deref()
.is_some_and(|value| value != "owner-only" && value != "anyone")
{
return Err(CliError::Usage(
"respond-to must be owner-only or anyone".into(),
));
}
let request = UpdateAgentDraft {
channel_id: channel_id.clone(),
agent_name: required(draft.agent_name, "agent name", MAX_NAME_CHARS)?,
display_name: optional(draft.display_name, "display name")?,
system_prompt: draft
.system_prompt
.map(|value| required(value, "system prompt", MAX_PROMPT_CHARS))
.transpose()?,
runtime: optional(draft.runtime, "runtime")?,
provider: optional(draft.provider, "provider")?,
model: optional(draft.model, "model")?,
respond_to,
};
if request.display_name.is_none()
&& request.system_prompt.is_none()
&& request.runtime.is_none()
&& request.provider.is_none()
&& request.model.is_none()
&& request.respond_to.is_none()
{
return Err(CliError::Usage(
"include at least one field to update".into(),
));
}
build(keys, owner, channel_id, "update", request)
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::observer::{decrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_TAG};
const CHANNEL: &str = "7c07e659-3610-42f4-9a5e-1e9973c09da9";
#[test]
fn create_is_owner_encrypted_and_matches_desktop_contract() {
let agent = Keys::generate();
let owner = Keys::generate();
let built = build_create(
&agent,
&owner.public_key(),
CreateAgentDraft {
channel_id: CHANNEL.into(),
display_name: "Research helper".into(),
system_prompt: "Find sources.".into(),
},
)
.unwrap();
assert_eq!(built.event.kind.as_u16(), 24_200);
let tags: Vec<Vec<String>> = built
.event
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect();
assert!(tags
.iter()
.any(|tag| tag == &["p", &owner.public_key().to_hex()]));
assert!(tags
.iter()
.any(|tag| tag == &[OBSERVER_AGENT_TAG, &agent.public_key().to_hex()]));
assert!(tags
.iter()
.any(|tag| tag == &[OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY]));
assert!(!tags
.iter()
.any(|tag| tag.first().map(String::as_str) == Some("h")));
let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
assert_eq!(payload["kind"], REQUEST_KIND);
assert_eq!(payload["channelId"], CHANNEL);
assert_eq!(payload["payload"]["type"], REQUEST_KIND);
assert_eq!(payload["payload"]["action"], "create");
assert_eq!(
payload["payload"]["request"]["displayName"],
"Research helper"
);
assert!(payload["payload"]["request"].get("runtime").is_none());
assert!(payload["payload"]["request"].get("respondTo").is_none());
}
#[test]
fn update_requires_a_change() {
let error = build_update(
&Keys::generate(),
&Keys::generate().public_key(),
UpdateAgentDraft {
channel_id: CHANNEL.into(),
agent_name: "Scout".into(),
display_name: None,
system_prompt: None,
runtime: None,
provider: None,
model: None,
respond_to: None,
},
)
.unwrap_err();
assert!(error.to_string().contains("at least one field"));
}
#[test]
fn create_rejects_invalid_channel() {
let error = build_create(
&Keys::generate(),
&Keys::generate().public_key(),
CreateAgentDraft {
channel_id: "general".into(),
display_name: "Scout".into(),
system_prompt: "Help".into(),
},
)
.unwrap_err();
assert!(error.to_string().contains("invalid channel UUID"));
}
}
+72
View File
@@ -0,0 +1,72 @@
use nostr::PublicKey;
use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft};
use crate::client::BuzzClient;
use crate::error::CliError;
use crate::validate::read_or_stdin;
use crate::{AgentsCmd, RespondToArg};
pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> {
let owner = client
.auth_tag_owner_hex()
.ok_or_else(|| CliError::Auth("agent draft requests require BUZZ_AUTH_TAG".into()))?;
let owner = PublicKey::parse(&owner)
.map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?;
let built = match command {
AgentsCmd::DraftCreate {
channel,
display_name,
system_prompt,
} => build_create(
client.keys(),
&owner,
CreateAgentDraft {
channel_id: channel,
display_name,
system_prompt: read_or_stdin(&system_prompt)?,
},
)?,
AgentsCmd::DraftUpdate {
channel,
agent_name,
display_name,
system_prompt,
runtime,
provider,
model,
respond_to,
} => build_update(
client.keys(),
&owner,
UpdateAgentDraft {
channel_id: channel,
agent_name,
display_name,
system_prompt: system_prompt
.map(|value| read_or_stdin(&value))
.transpose()?,
runtime,
provider,
model,
respond_to: respond_to.map(RespondToArg::to_wire),
},
)?,
};
let response = client.publish_ephemeral_event(built.event).await?;
let mut output: serde_json::Value = serde_json::from_str(&response)
.map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?;
if let Some(object) = output.as_object_mut() {
object.insert("request_id".into(), built.request_id.into());
object.insert("action".into(), built.action.into());
object.insert("saved".into(), false.into());
object.insert(
"message".into(),
"Draft sent to Buzz Desktop for owner review. Nothing changes until the owner saves it."
.into(),
);
}
println!("{output}");
Ok(())
}
+1
View File
@@ -1,3 +1,4 @@
pub mod agents;
pub mod channels;
pub mod dms;
pub mod emoji;
+64
View File
@@ -1,3 +1,4 @@
pub mod agent_management;
mod client;
mod commands;
mod error;
@@ -160,6 +161,9 @@ pub enum OutputFormat {
#[derive(Subcommand)]
enum Cmd {
/// Draft owner-reviewed agent creation and updates
#[command(subcommand)]
Agents(AgentsCmd),
/// Send, read, search, and manage messages
#[command(subcommand)]
Messages(MessagesCmd),
@@ -219,6 +223,62 @@ enum Cmd {
Moderation(ModerationCmd),
}
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum RespondToArg {
#[value(name = "owner-only")]
OwnerOnly,
#[value(name = "anyone")]
Anyone,
}
impl RespondToArg {
fn to_wire(self) -> String {
match self {
Self::OwnerOnly => "owner-only",
Self::Anyone => "anyone",
}
.to_string()
}
}
#[derive(Subcommand)]
pub enum AgentsCmd {
/// Open a prefilled create-agent form in the owner's Buzz Desktop
DraftCreate {
/// Current channel UUID; the new agent is added here after save
#[arg(long)]
channel: String,
/// Proposed agent name
#[arg(long)]
display_name: String,
/// Proposed instructions; use '-' to read from stdin
#[arg(long)]
system_prompt: String,
},
/// Open a prefilled edit-agent form in the owner's Buzz Desktop
DraftUpdate {
/// Current channel UUID
#[arg(long)]
channel: String,
/// Current name of the personal agent to update
#[arg(long)]
agent_name: String,
#[arg(long)]
display_name: Option<String>,
/// Replacement instructions; use '-' to read from stdin
#[arg(long)]
system_prompt: Option<String>,
#[arg(long)]
runtime: Option<String>,
#[arg(long)]
provider: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long, value_enum)]
respond_to: Option<RespondToArg>,
},
}
#[derive(Subcommand)]
pub enum MessagesCmd {
/// Send a message to a channel
@@ -1564,6 +1624,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
match cli.command {
Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await,
Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await,
Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await,
Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await,
@@ -1600,6 +1661,7 @@ mod tests {
#[test]
fn command_inventory_is_stable() {
let expected_groups: Vec<&str> = vec![
"agents",
"canvas",
"channels",
"dms",
@@ -1660,6 +1722,7 @@ mod tests {
}
let cmd = Cli::command();
assert_eq!(names(&cmd, "agents"), vec!["draft-create", "draft-update"]);
assert_eq!(
names(&cmd, "messages"),
vec![
@@ -1758,6 +1821,7 @@ mod tests {
#[test]
fn subcommand_counts_are_stable() {
let expected: Vec<(&str, usize)> = vec![
("agents", 2),
("canvas", 2),
("channels", 16),
("dms", 4),
+3
View File
@@ -30,6 +30,9 @@ tempfile = "3"
ignore = "0.4.25"
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# wss:// tool calls use rustls via tokio-tungstenite/reqwest. The workspace
# pulls both ring and aws-lc-rs transitively, so install ring explicitly.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# view_image tool: HTTP fetch (workspace reqwest is already used by buzz-cli;
# adding it here is essentially free), base64 encoding, and decode/resize.
reqwest = { workspace = true }
+4
View File
@@ -173,6 +173,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
}
async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
// HTTPS clients invoked through this MCP process need a Rustls provider;
// repeated installation is harmless.
let _ = rustls::crypto::ring::default_provider().install_default();
// buzz CLI needs tokio (async HTTP client).
if cmd == "buzz" {
std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
+1
View File
@@ -38,6 +38,7 @@ export default defineConfig({
"**/observer-feed-screenshots.spec.ts",
"**/core-memory-screenshots.spec.ts",
"**/activity-scope-label-screenshots.spec.ts",
"**/welcome-agent-modal-screenshots.spec.ts",
"**/local-archive-screenshots.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/agent-error-state-screenshots.spec.ts",
+1 -1
View File
@@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4;
/// Template content version for SKILL.md.
/// Bump this when changing `nest_skill.md` to trigger refresh on existing installs.
const NEST_SKILL_VERSION: u32 = 3;
const NEST_SKILL_VERSION: u32 = 4;
const BEGIN_MARKER: &str = "<!-- BEGIN BUZZ MANAGED";
const END_MARKER: &str = "<!-- END BUZZ MANAGED -->";
@@ -1,8 +1,9 @@
---
name: buzz-cli
description: >
Buzz CLI for relay operations: messaging, channels, DMs, users, workflows,
feed, reactions, canvas, social, repos, uploads, and agent memory.
Buzz CLI for relay operations: owner-reviewed agent drafts, messaging,
channels, DMs, users, workflows, feed, reactions, canvas, social, repos,
uploads, and agent memory.
version: 1
---
@@ -14,8 +15,32 @@ version: 1
`BUZZ_RELAY_URL` defaults to `http://localhost:3000`. In development, the user may need to set this to a staging or production relay URL.
`BUZZ_AUTH_TAG` is required for `buzz agents draft-create` and `buzz agents draft-update` because those commands send owner-reviewed Desktop drafts. If missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat.
Run the bundled CLI with `--help` and `<command> <subcommand> --help` to discover all flags, arguments, and usage. This skill documents only what `--help` cannot tell you.
## Conversational Agent Management
When someone naturally asks to create an agent, ask for at most two things: the agent's **name** and **what it should do day-to-day**. Turn the user's rough purpose into the system prompt yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the request is genuinely ambiguous. Then run:
```bash
buzz agents draft-create \
--channel <current-channel-uuid> \
--display-name "Research helper" \
--system-prompt "Find reliable sources and summarize them concisely."
```
Use the UUID from the current Buzz `[Context]`; do not ask the user for it. Do not ask about runtime, provider, model, credentials, environment variables, or access. Desktop uses the machine's real defaults, and new agents start as **Only me**. The command sends an encrypted draft to the owner's Desktop. It does not create the agent until the owner reviews and saves the form, so report the result as “ready for review,” never “created.”
For an explicit change to an existing personal agent, use:
```bash
buzz agents draft-update --channel <uuid> --agent-name "Current name" \
--system-prompt "Updated instructions"
```
Run `buzz agents draft-update --help` for optional runtime, provider, model, rename, and access changes. Prefer these CLI commands over any legacy MCP agent-management tools.
## Git Repositories
Buzz hosts real git repos, and **you can own one yourself** — no human key needed. `repos create` signs the announcement with *your* key, so the repo is owned by whoever runs it; the owner segment in the clone URL is your own pubkey (hex, not a username). Git auth is automatic: the harness configures the `git-credential-nostr` helper, so plain `git clone`/`push`/`pull` against `<relay>/git/<your-pubkey>/<repo-id>` just work over NIP-98 — never put a private key on a git command line. Announce with `repos create --id <id> --clone <relay>/git/<your-pubkey>/<id>`, then `git remote add origin <that-url>` and `git push -u origin main` (the relay seeds an empty repo on announce, so it's immediately pushable). Requires git 2.46+ for the credential protocol.
@@ -26,7 +51,7 @@ Output varies by command group — `--help` shows flags but not response shapes.
**Read commands** (messages, channels, users, feed, workflows): normalized JSON arrays with `sig` stripped. Fields: `{id, pubkey, kind, content, created_at, tags}` for events; command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), workflows (`{workflow_id, content, created_at, pubkey}`).
**Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`.
**Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`. Agent draft commands add `{request_id, action, saved: false}` because they only open an owner-reviewed Desktop draft.
**Exceptions to the above patterns:**
+5 -3
View File
@@ -41,6 +41,8 @@ import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh
import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy";
import { usePersonaSync } from "@/features/agents/lib/usePersonaSync";
import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion";
import { AgentManagementDialogs } from "@/features/agents/ui/AgentManagementDialogs";
import { RequestedAgentCreateDialogs } from "@/features/agents/ui/RequestedAgentCreateDialogs";
import {
usePresenceSession,
usePresenceSubscription,
@@ -773,9 +775,7 @@ export function AppShell() {
onUpdateCommunity={communitiesHook.updateCommunity}
onRemoveCommunity={communitiesHook.removeCommunity}
onSwitchCommunity={handleSwitchCommunity}
onCreateAgent={() =>
void goAgents().then(requestOpenCreateAgent)
}
onCreateAgent={() => requestOpenCreateAgent()}
selfPresenceStatus={presenceSession.currentStatus}
communities={communitiesHook.communities}
onCreateChannel={async ({
@@ -904,6 +904,8 @@ export function AppShell() {
/>
</div>
)}
<RequestedAgentCreateDialogs />
<AgentManagementDialogs />
<AppShellOverlays
activeChannel={managedChannel}
browseDialogType={browseDialogType}
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
AGENT_MANAGEMENT_REQUEST,
createInputFromRequest,
requestTargetsEditablePersona,
parseAgentManagementRequest,
} from "./agentManagement.ts";
const CHANNEL_ID = "7c07e659-3610-42f4-9a5e-1e9973c09da9";
function createPayload(overrides = {}) {
return {
type: AGENT_MANAGEMENT_REQUEST,
action: "create",
requestId: "request-1",
request: {
channelId: CHANNEL_ID,
displayName: "Research helper",
systemPrompt: "Find reliable sources and summarize them.",
},
...overrides,
};
}
test("parses the narrow no-secret create request", () => {
assert.deepEqual(
parseAgentManagementRequest(createPayload()),
createPayload(),
);
});
test("rejects an agent-management request with extra secret-shaped fields", () => {
const payload = createPayload();
payload.request.apiKey = "should-not-be-accepted";
assert.equal(parseAgentManagementRequest(payload), null);
});
test("chat creation cannot choose runtime, provider, model, or access", () => {
for (const [field, value] of [
["runtime", "claude"],
["provider", "anthropic"],
["model", "claude-opus"],
["respondTo", "anyone"],
]) {
const payload = createPayload();
payload.request[field] = value;
assert.equal(parseAgentManagementRequest(payload), null);
}
});
test("chat creation leaves advanced behavior unset so the form stays collapsed", () => {
const parsed = parseAgentManagementRequest(createPayload());
assert.ok(parsed && parsed.action === "create");
assert.deepEqual(createInputFromRequest(parsed), {
displayName: "Research helper",
systemPrompt: "Find reliable sources and summarize them.",
});
});
test("requires the originating channel for profile updates", () => {
const payload = {
type: AGENT_MANAGEMENT_REQUEST,
action: "update",
requestId: "request-2",
request: {
agentName: "Review helper",
systemPrompt: "Review changes concisely.",
},
};
assert.equal(parseAgentManagementRequest(payload), null);
});
test("uses an agent's current name, never an internal profile ID", () => {
const payload = {
type: AGENT_MANAGEMENT_REQUEST,
action: "update",
requestId: "request-3",
request: {
channelId: CHANNEL_ID,
agentName: "Review helper",
systemPrompt: "Review changes concisely.",
},
};
assert.deepEqual(parseAgentManagementRequest(payload), payload);
});
test("allows agents to update only personal, editable profiles", () => {
assert.equal(
requestTargetsEditablePersona({ isBuiltIn: false, sourceTeam: null }),
true,
);
assert.equal(
requestTargetsEditablePersona({ isBuiltIn: true, sourceTeam: null }),
false,
);
assert.equal(
requestTargetsEditablePersona({ isBuiltIn: false, sourceTeam: "team" }),
false,
);
});
@@ -0,0 +1,150 @@
import type {
AgentPersona,
CreatePersonaInput,
RespondToMode,
} from "@/shared/api/types";
export const AGENT_MANAGEMENT_REQUEST = "agent_management_request" as const;
export type AgentManagementCreateRequest = {
type: typeof AGENT_MANAGEMENT_REQUEST;
action: "create";
requestId: string;
request: {
channelId: string;
displayName: string;
systemPrompt: string;
};
};
export type AgentManagementUpdateRequest = {
type: typeof AGENT_MANAGEMENT_REQUEST;
action: "update";
requestId: string;
request: {
channelId: string;
agentName: string;
displayName?: string;
systemPrompt?: string;
runtime?: string;
provider?: string;
model?: string;
respondTo?: RespondToMode;
};
};
export type AgentManagementRequest =
| AgentManagementCreateRequest
| AgentManagementUpdateRequest;
function isText(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isRespondTo(value: unknown): value is RespondToMode | undefined {
return value === undefined || value === "owner-only" || value === "anyone";
}
function hasOnlyKeys(
value: Record<string, unknown>,
allowed: readonly string[],
) {
return Object.keys(value).every((key) => allowed.includes(key));
}
/** Parses only the deliberately narrow no-secret agent-management request contract. */
export function parseAgentManagementRequest(
value: unknown,
): AgentManagementRequest | null {
if (typeof value !== "object" || value === null) return null;
const payload = value as Record<string, unknown>;
if (
payload.type !== AGENT_MANAGEMENT_REQUEST ||
!isText(payload.requestId) ||
(payload.action !== "create" && payload.action !== "update") ||
typeof payload.request !== "object" ||
payload.request === null
) {
return null;
}
const request = payload.request as Record<string, unknown>;
if (payload.action === "create") {
if (!hasOnlyKeys(request, ["channelId", "displayName", "systemPrompt"])) {
return null;
}
if (
!isText(request.channelId) ||
!isText(request.displayName) ||
!isText(request.systemPrompt)
) {
return null;
}
return {
type: AGENT_MANAGEMENT_REQUEST,
action: "create",
requestId: payload.requestId,
request: {
channelId: request.channelId,
displayName: request.displayName,
systemPrompt: request.systemPrompt,
},
};
}
if (
!isRespondTo(request.respondTo) ||
!hasOnlyKeys(request, [
"channelId",
"agentName",
"displayName",
"systemPrompt",
"runtime",
"provider",
"model",
"respondTo",
]) ||
!isText(request.channelId) ||
!isText(request.agentName)
) {
return null;
}
const changes = {
...(isText(request.displayName)
? { displayName: request.displayName }
: {}),
...(isText(request.systemPrompt)
? { systemPrompt: request.systemPrompt }
: {}),
...(isText(request.runtime) ? { runtime: request.runtime } : {}),
...(isText(request.provider) ? { provider: request.provider } : {}),
...(isText(request.model) ? { model: request.model } : {}),
...(request.respondTo ? { respondTo: request.respondTo } : {}),
};
if (Object.keys(changes).length === 0) return null;
return {
type: AGENT_MANAGEMENT_REQUEST,
action: "update",
requestId: payload.requestId,
request: {
channelId: request.channelId,
agentName: request.agentName,
...changes,
},
};
}
export function requestTargetsEditablePersona(
persona: AgentPersona | undefined,
): persona is AgentPersona {
return Boolean(persona && !persona.isBuiltIn && !persona.sourceTeam);
}
export function createInputFromRequest(
request: Extract<AgentManagementRequest, { action: "create" }>,
): CreatePersonaInput {
return {
displayName: request.request.displayName,
systemPrompt: request.request.systemPrompt,
};
}
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import test from "node:test";
import { classifyAgentManagementOrigin } from "./agentManagementBuffer.ts";
const AGENT = "a".repeat(64);
const CHANNEL = "channel-1";
const OWNED_AGENT = [{ pubkey: AGENT }];
const SHARED_CHANNEL = [
{ id: CHANNEL, isMember: true, memberPubkeys: [AGENT] },
];
test("buffers a draft until ownership and channel data resolve", () => {
assert.equal(
classifyAgentManagementOrigin(undefined, SHARED_CHANNEL, AGENT, CHANNEL),
"buffer",
);
assert.equal(
classifyAgentManagementOrigin(OWNED_AGENT, undefined, AGENT, CHANNEL),
"buffer",
);
});
test("accepts an owned agent drafting from a shared channel", () => {
assert.equal(
classifyAgentManagementOrigin(OWNED_AGENT, SHARED_CHANNEL, AGENT, CHANNEL),
"accept",
);
});
test("rejects a draft when the owner or agent is outside the claimed channel", () => {
assert.equal(
classifyAgentManagementOrigin(
OWNED_AGENT,
[{ id: CHANNEL, isMember: false, memberPubkeys: [AGENT] }],
AGENT,
CHANNEL,
),
"reject",
);
assert.equal(
classifyAgentManagementOrigin(
OWNED_AGENT,
[{ id: CHANNEL, isMember: true, memberPubkeys: [] }],
AGENT,
CHANNEL,
),
"reject",
);
});
test("rejects a draft from an agent this Desktop does not own", () => {
assert.equal(
classifyAgentManagementOrigin(
[{ pubkey: "b".repeat(64) }],
SHARED_CHANNEL,
AGENT,
CHANNEL,
),
"reject",
);
});
@@ -0,0 +1,29 @@
import type { Channel, ManagedAgent } from "@/shared/api/types";
/**
* Defers the trust decision until both ownership and channel membership have
* initialized. A draft may open only when its owned sender and the owner share
* the claimed originating channel.
*/
export function classifyAgentManagementOrigin(
agents: readonly Pick<ManagedAgent, "pubkey">[] | undefined,
channels:
| readonly Pick<Channel, "id" | "isMember" | "memberPubkeys">[]
| undefined,
agentPubkey: string,
channelId: string,
): "buffer" | "accept" | "reject" {
if (agents === undefined || channels === undefined) return "buffer";
const normalizedAgentPubkey = agentPubkey.toLowerCase();
const isOwnedAgent = agents.some(
(agent) => agent.pubkey.toLowerCase() === normalizedAgentPubkey,
);
const originChannel = channels.find((channel) => channel.id === channelId);
return isOwnedAgent &&
originChannel?.isMember === true &&
originChannel.memberPubkeys.some(
(pubkey) => pubkey.toLowerCase() === normalizedAgentPubkey,
)
? "accept"
: "reject";
}
@@ -0,0 +1,4 @@
export type AgentChannelAttachmentFailure = {
channelName: string;
error: string;
};
@@ -6,6 +6,10 @@ import type { ControlResultFrame } from "@/shared/api/types";
import { putAgentSessionConfig } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { decryptObserverEvent } from "@/shared/api/tauriObserver";
import {
parseAgentManagementRequest,
type AgentManagementRequest,
} from "./agentManagement";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useQueryClient } from "@tanstack/react-query";
import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks";
@@ -22,6 +26,7 @@ import {
} from "./ui/agentSessionTranscript";
const MAX_OBSERVER_EVENTS = 3000;
const MAX_PENDING_UNKNOWN_AGENT_FRAMES = 100;
export type ObserverSnapshot = {
connectionState: ConnectionState;
@@ -95,6 +100,10 @@ const controlResultListeners = new Map<
Set<(frame: ControlResultFrame) => void>
>();
const agentManagementListeners = new Set<
(agentPubkey: string, request: AgentManagementRequest) => void
>();
// Normalized pubkeys of agents we are actively managing. Only events whose
// "agent" tag matches an entry here will be decrypted (defense-in-depth).
//
@@ -105,6 +114,7 @@ const controlResultListeners = new Map<
// recompute the union, so co-mounted callers no longer clobber each other.
const knownAgentPubkeys = new Set<string>();
const knownAgentsBySubscription = new Map<string, Set<string>>();
const pendingUnknownAgentFrames: RelayEvent[] = [];
// Callback invoked when session_config_captured is received, so React Query
// can invalidate the config-surface query for the affected agent. Wired up
@@ -135,6 +145,14 @@ function registerKnownAgents(
new Set(pubkeys.map((pubkey) => normalizePubkey(pubkey))),
);
recomputeKnownAgentPubkeys();
if (knownAgentPubkeys.size > 0 && pendingUnknownAgentFrames.length > 0) {
const pending = pendingUnknownAgentFrames.splice(0);
for (const event of pending) {
eventProcessingQueue = eventProcessingQueue.then(() =>
handleRelayObserverEvent(event, generation),
);
}
}
}
function unregisterKnownAgents(subscriptionId: string) {
@@ -329,9 +347,16 @@ async function handleRelayObserverEvent(
return;
}
// Verify agent is known/trusted before decrypting.
// Silently drop events from agents we are not managing.
// Ownership data arrives asynchronously during startup. Buffer raw signed
// frames until the first trusted-agent set is registered, then re-run this
// same gate. Once initialized, unknown agents are rejected immediately.
if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) {
if (knownAgentsBySubscription.size === 0 || knownAgentPubkeys.size === 0) {
pendingUnknownAgentFrames.push(event);
if (pendingUnknownAgentFrames.length > MAX_PENDING_UNKNOWN_AGENT_FRAMES) {
pendingUnknownAgentFrames.shift();
}
}
return;
}
@@ -366,6 +391,12 @@ async function handleRelayObserverEvent(
}
}
appendAgentEvent(agentPubkey, parsed);
const managementRequest = parseAgentManagementRequest(parsed.payload);
if (managementRequest) {
for (const listener of agentManagementListeners) {
listener(agentPubkey, managementRequest);
}
}
if (parsed.kind === "session_config_captured") {
void putAgentSessionConfig(agentPubkey, parsed.payload);
onSessionConfigCaptured?.(agentPubkey);
@@ -475,6 +506,15 @@ function dispatchControlResult(agentPubkey: string, payload: unknown) {
* unsubscribe function. Used by the ModelPicker to learn the async outcome of
* a `switch_model` frame.
*/
export function subscribeAgentManagementRequests(
listener: (agentPubkey: string, request: AgentManagementRequest) => void,
) {
agentManagementListeners.add(listener);
return () => {
agentManagementListeners.delete(listener);
};
}
export function subscribeControlResults(
agentPubkey: string,
listener: (frame: ControlResultFrame) => void,
@@ -700,7 +740,9 @@ export function resetAgentObserverStore() {
archiveEventsByChannel.clear();
knownAgentPubkeys.clear();
knownAgentsBySubscription.clear();
pendingUnknownAgentFrames.length = 0;
latestLiveSessionByAgentChannel.clear();
agentManagementListeners.clear();
onSessionConfigCaptured = null;
connectionState = "idle";
errorMessage = null;
@@ -1,25 +1,37 @@
const OPEN_CREATE_AGENT_EVENT = "buzz:open-create-agent";
let pendingOpenCreateAgent = false;
export type OpenCreateAgentOptions = {
channelId?: string;
channelName?: string;
};
export function requestOpenCreateAgent() {
pendingOpenCreateAgent = true;
window.dispatchEvent(new Event(OPEN_CREATE_AGENT_EVENT));
let pendingOpenCreateAgent: OpenCreateAgentOptions | null = null;
export function requestOpenCreateAgent(options: OpenCreateAgentOptions = {}) {
pendingOpenCreateAgent = options;
window.dispatchEvent(
new CustomEvent<OpenCreateAgentOptions>(OPEN_CREATE_AGENT_EVENT, {
detail: options,
}),
);
}
export function consumePendingOpenCreateAgent() {
if (!pendingOpenCreateAgent) {
return false;
}
pendingOpenCreateAgent = false;
return true;
const pending = pendingOpenCreateAgent;
pendingOpenCreateAgent = null;
return pending;
}
export function subscribeOpenCreateAgent(handler: () => void) {
function handleOpenCreateAgent() {
pendingOpenCreateAgent = false;
handler();
export function subscribeOpenCreateAgent(
handler: (options: OpenCreateAgentOptions) => void,
) {
function handleOpenCreateAgent(event: Event) {
pendingOpenCreateAgent = null;
handler(
event instanceof CustomEvent
? (event.detail as OpenCreateAgentOptions)
: {},
);
}
window.addEventListener(OPEN_CREATE_AGENT_EVENT, handleOpenCreateAgent);
@@ -21,6 +21,7 @@ import {
type AgentDialogCreateProps = {
mode: "definition";
initialValues?: CreatePersonaInput | null;
onOpenChange: (open: boolean) => void;
definitionError: Error | null;
isDefinitionPending: boolean;
@@ -101,6 +102,7 @@ export function AgentDialog(props: AgentDialogProps) {
}
function AgentCreateDialogRouter({
initialValues: providedInitialValues,
onOpenChange,
definitionError,
isDefinitionPending,
@@ -110,8 +112,8 @@ function AgentCreateDialogRouter({
}: AgentDialogCreateProps) {
const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft);
const initialValues = React.useMemo(
() => createPersonaDialogState().initialValues,
[],
() => providedInitialValues ?? createPersonaDialogState().initialValues,
[providedInitialValues],
);
const copy = createPersonaDialogState();
@@ -0,0 +1,60 @@
import { useAgentManagement } from "@/features/agents/useAgentManagement";
import { AgentDialog } from "./AgentDialog";
import { SecretRevealDialog } from "./SecretRevealDialog";
/** Global review surfaces opened by owned agents through the Buzz harness. */
export function AgentManagementDialogs() {
const management = useAgentManagement();
return (
<>
{management.request?.action === "create" ? (
<AgentDialog
definitionError={
management.error ? new Error(management.error) : null
}
initialValues={management.createInitialValues}
isDefinitionPending={management.isPending}
mode="definition"
onOpenChange={(open) => {
if (!open) management.dismiss();
}}
onSubmitDefinition={management.submitCreate}
runtimes={management.runtimes}
runtimesLoading={management.runtimesLoading}
/>
) : null}
{management.createdAgent ? (
<SecretRevealDialog
attachmentFailure={management.attachmentFailure}
created={management.createdAgent}
isRetryingAttachment={management.isRetryingAttachment}
onOpenChange={(open) => {
if (!open) management.dismissCreatedAgent();
}}
onRetryAttachment={() => {
void management.retryAttachment();
}}
/>
) : null}
{management.request?.action === "update" ? (
<AgentDialog
description=""
error={management.editError ? new Error(management.editError) : null}
initialValues={management.editInitialValues}
isPending={management.isPending}
mode="definition-edit"
onOpenChange={(open) => {
if (!open) management.dismiss();
}}
onSubmit={management.submitUpdate}
open
runtimes={management.runtimes}
runtimesLoading={management.runtimesLoading}
submitLabel="Save changes"
title="Edit agent"
/>
) : null}
</>
);
}
+2 -23
View File
@@ -1,8 +1,4 @@
import * as React from "react";
import {
consumePendingOpenCreateAgent,
subscribeOpenCreateAgent,
} from "@/features/agents/openCreateAgentEvent";
import {
consumePendingSnapshotImport,
subscribeSnapshotImport,
@@ -60,19 +56,6 @@ export function AgentsView() {
teamActions.updateTeamMutation.isPending ||
teamActions.deleteTeamMutation.isPending;
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only shortcut subscription; openUnifiedCreate only calls stable setState-backed callbacks
React.useEffect(() => {
// The app-wide "create agent" shortcut routes to the unified definition
// flow (B5): one create path, always starting the agent after creation.
if (consumePendingOpenCreateAgent()) {
openUnifiedCreate();
}
return subscribeOpenCreateAgent(() => {
openUnifiedCreate();
});
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable
React.useEffect(() => {
// Consume a snapshot import that was enqueued before navigation (e.g. from
@@ -222,9 +205,7 @@ export function AgentsView() {
isDefinitionPending={personas.isPending}
mode="definition"
onOpenChange={(open) => {
if (!open) {
setIsCreateDialogOpen(false);
}
if (!open) setIsCreateDialogOpen(false);
}}
onSubmitDefinition={personas.handleSubmit}
runtimes={personas.acpRuntimesQuery.data ?? []}
@@ -257,9 +238,7 @@ export function AgentsView() {
<SecretRevealDialog
created={personas.createdAgent}
onOpenChange={(open) => {
if (!open) {
personas.setCreatedAgent(null);
}
if (!open) personas.dismissCreatedAgent();
}}
/>
) : null}
@@ -0,0 +1,76 @@
import * as React from "react";
import {
consumePendingOpenCreateAgent,
subscribeOpenCreateAgent,
type OpenCreateAgentOptions,
} from "@/features/agents/openCreateAgentEvent";
import { AgentDialog } from "./AgentDialog";
import { SecretRevealDialog } from "./SecretRevealDialog";
import { usePersonaActions } from "./usePersonaActions";
/** App-level create flow so contextual entry points do not navigate away. */
export function RequestedAgentCreateDialogs() {
const personas = usePersonaActions();
const [targetChannel, setTargetChannel] = React.useState<{
id: string;
name: string;
} | null>(null);
const [isOpen, setIsOpen] = React.useState(false);
const openCreate = React.useEffectEvent((options: OpenCreateAgentOptions) => {
personas.prepareCreate();
setTargetChannel(
options.channelId && options.channelName
? { id: options.channelId, name: options.channelName }
: null,
);
setIsOpen(true);
});
React.useEffect(() => {
const pending = consumePendingOpenCreateAgent();
if (pending) openCreate(pending);
return subscribeOpenCreateAgent(openCreate);
}, []);
return (
<>
{isOpen ? (
<AgentDialog
definitionError={
personas.createPersonaMutation.error instanceof Error
? personas.createPersonaMutation.error
: null
}
isDefinitionPending={personas.isPending}
mode="definition"
onOpenChange={(open) => {
if (!open) {
setIsOpen(false);
setTargetChannel(null);
}
}}
onSubmitDefinition={(input, intent, backendIntent) =>
personas.handleSubmit(input, intent, backendIntent, targetChannel)
}
runtimes={personas.acpRuntimesQuery.data ?? []}
runtimesLoading={personas.acpRuntimesQuery.isLoading}
/>
) : null}
{personas.createdAgent ? (
<SecretRevealDialog
attachmentFailure={personas.attachmentFailure}
created={personas.createdAgent}
isRetryingAttachment={personas.isRetryingAttachment}
onOpenChange={(open) => {
if (!open) personas.dismissCreatedAgent();
}}
onRetryAttachment={() => {
void personas.retryAttachment();
}}
/>
) : null}
</>
);
}
@@ -1,3 +1,4 @@
import type { AgentChannelAttachmentFailure } from "@/features/agents/channelAttachmentFailure";
import type { CreateManagedAgentResponse } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
@@ -10,11 +11,17 @@ import {
import { CopyButton } from "./CopyButton";
export function SecretRevealDialog({
attachmentFailure,
created,
isRetryingAttachment = false,
onOpenChange,
onRetryAttachment,
}: {
attachmentFailure?: AgentChannelAttachmentFailure | null;
created: CreateManagedAgentResponse | null;
isRetryingAttachment?: boolean;
onOpenChange: (open: boolean) => void;
onRetryAttachment?: () => void;
}) {
return (
<Dialog onOpenChange={onOpenChange} open={created !== null}>
@@ -61,6 +68,17 @@ export function SecretRevealDialog({
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{created.spawnError}
</p>
) : attachmentFailure ? (
<div
className="space-y-1 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
role="alert"
>
<p>
{created.agent.name} was created, but couldn’t be added to
#{attachmentFailure.channelName}.
</p>
<p>{attachmentFailure.error}</p>
</div>
) : (
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
{created.agent.name} is ready
@@ -75,8 +93,19 @@ export function SecretRevealDialog({
) : null}
</div>
<div className="flex justify-end border-t border-border/60 px-6 py-4">
<div className="flex justify-end gap-2 border-t border-border/60 px-6 py-4">
{attachmentFailure && onRetryAttachment ? (
<Button
disabled={isRetryingAttachment}
onClick={onRetryAttachment}
size="sm"
type="button"
>
{isRetryingAttachment ? "Trying again…" : "Try again"}
</Button>
) : null}
<Button
disabled={isRetryingAttachment}
onClick={() => onOpenChange(false)}
size="sm"
type="button"
@@ -18,6 +18,7 @@ import {
type AgentSnapshotImportResult,
} from "@/features/agents/hooks";
import { getPersonaLibraryState } from "@/features/agents/lib/catalog";
import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment";
import type {
SnapshotFormat,
SnapshotMemoryLevel,
@@ -25,7 +26,7 @@ import type {
import type {
AcpRuntime,
AgentPersona,
CreateManagedAgentResponse,
Channel,
CreatePersonaInput,
ManagedAgent,
UpdatePersonaInput,
@@ -137,8 +138,7 @@ export function usePersonaActions() {
>(null);
const [personaFeedbackSurface, setPersonaFeedbackSurface] =
React.useState<PersonaFeedbackSurface>("library");
const [createdAgent, setCreatedAgent] =
React.useState<CreateManagedAgentResponse | null>(null);
const createdAgentAttachment = useCreatedAgentChannelAttachment();
const [isPersonaSubmitPending, setIsPersonaSubmitPending] =
React.useState(false);
@@ -172,6 +172,7 @@ export function usePersonaActions() {
input: CreatePersonaInput | UpdatePersonaInput,
intent?: AgentCreateIntent,
backendIntent?: BackendIntent | null,
targetChannel?: Pick<Channel, "id" | "name"> | null,
): Promise<boolean> {
if (isPersonaSubmitPending) {
return false;
@@ -224,7 +225,10 @@ export function usePersonaActions() {
try {
const created = await createAgentMutation.mutateAsync(agentInput);
setCreatedAgent(created);
await createdAgentAttachment.presentCreatedAgent(
created,
targetChannel,
);
if (created.spawnError) {
setPersonaErrorMessage(
`${persona.displayName} was created, but it did not start: ${created.spawnError}`,
@@ -490,8 +494,7 @@ export function usePersonaActions() {
personaNoticeMessage,
personaErrorMessage,
personaFeedbackSurface,
createdAgent,
setCreatedAgent,
...createdAgentAttachment,
handleSubmit,
handleDelete,
handleSetActive,
@@ -0,0 +1,316 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
createInputFromRequest,
requestTargetsEditablePersona,
type AgentManagementRequest,
} from "./agentManagement";
import { subscribeAgentManagementRequests } from "./observerRelayStore";
import {
managedAgentsQueryKey,
personasQueryKey,
useAcpRuntimesQuery,
useCreateManagedAgentMutation,
useCreatePersonaMutation,
useManagedAgentsQuery,
usePersonasQuery,
useUpdatePersonaMutation,
} from "./hooks";
import {
availableRuntimesForStart,
buildInstanceInputForDefinition,
mintDefinitionWithPreflight,
type BackendIntent,
} from "./lib/instanceInputForDefinition";
import { useCreatedAgentChannelAttachment } from "./useCreatedAgentChannelAttachment";
import { classifyAgentManagementOrigin } from "./agentManagementBuffer";
import { useChannelsQuery } from "@/features/channels/hooks";
import { resolveManagedAgentAvatarUrl } from "./ui/managedAgentAvatar";
import type { AgentCreateIntent } from "./ui/agentCreateIntent";
import { editPersonaDialogState } from "./ui/personaDialogState";
import type {
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
import { meshPrepareRelayMeshClient } from "@/shared/api/tauriMesh";
function updateInputFromRequest(
request: Extract<AgentManagementRequest, { action: "update" }>,
current: UpdatePersonaInput,
): UpdatePersonaInput {
const changes = request.request;
return {
...current,
displayName: changes.displayName ?? current.displayName,
systemPrompt: changes.systemPrompt ?? current.systemPrompt,
runtime: changes.runtime ?? current.runtime,
provider: changes.provider ?? current.provider,
model: changes.model ?? current.model,
...(changes.respondTo
? {
behavior: {
respondTo: changes.respondTo,
respondToAllowlist: [],
parallelism: current.behavior?.parallelism,
},
}
: {}),
};
}
export function useAgentManagement() {
const queryClient = useQueryClient();
const personasQuery = usePersonasQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const channelsQuery = useChannelsQuery();
const runtimesQuery = useAcpRuntimesQuery({ enabled: true });
const createPersonaMutation = useCreatePersonaMutation();
const updatePersonaMutation = useUpdatePersonaMutation();
const createAgentMutation = useCreateManagedAgentMutation();
const [request, setRequest] = React.useState<AgentManagementRequest | null>(
null,
);
const [error, setError] = React.useState<string | null>(null);
const createdAgentAttachment = useCreatedAgentChannelAttachment();
const seenRequestIds = React.useRef(new Set<string>());
const pendingRequestId = React.useRef<string | null>(null);
const sourceAgentPubkey = React.useRef<string | null>(null);
const managedAgentsRef = React.useRef(managedAgentsQuery.data);
const channelsRef = React.useRef(channelsQuery.data);
const bufferedRequestsRef = React.useRef<
Array<{ agentPubkey: string; request: AgentManagementRequest }>
>([]);
const acceptOwnedRequest = React.useEffectEvent(
(agentPubkey: string, next: AgentManagementRequest) => {
if (
classifyAgentManagementOrigin(
managedAgentsRef.current,
channelsRef.current,
agentPubkey,
next.request.channelId,
) !== "accept" ||
seenRequestIds.current.has(next.requestId)
) {
return;
}
seenRequestIds.current.add(next.requestId);
setError(null);
if (pendingRequestId.current === null) {
pendingRequestId.current = next.requestId;
sourceAgentPubkey.current = agentPubkey;
setRequest(next);
}
},
);
React.useEffect(() => {
managedAgentsRef.current = managedAgentsQuery.data;
channelsRef.current = channelsQuery.data;
if (managedAgentsQuery.data && channelsQuery.data) {
const buffered = bufferedRequestsRef.current.splice(0);
for (const candidate of buffered) {
acceptOwnedRequest(candidate.agentPubkey, candidate.request);
}
}
}, [channelsQuery.data, managedAgentsQuery.data]);
React.useEffect(
() =>
subscribeAgentManagementRequests((agentPubkey, next) => {
// Observer frames are owner-scoped and authenticated. Any managed agent
// this Desktop owns may draft a change; defer the ownership decision
// until the managed-agent query has initialized so ephemeral requests
// cannot disappear during startup.
if (
classifyAgentManagementOrigin(
managedAgentsRef.current,
channelsRef.current,
agentPubkey,
next.request.channelId,
) === "buffer"
) {
bufferedRequestsRef.current.push({ agentPubkey, request: next });
if (bufferedRequestsRef.current.length > 100) {
bufferedRequestsRef.current.shift();
}
return;
}
acceptOwnedRequest(agentPubkey, next);
}),
[],
);
const matchingPersonas = React.useMemo(() => {
if (request?.action !== "update") return [];
const target = request.request.agentName.trim().toLocaleLowerCase();
return (personasQuery.data ?? []).filter(
(persona) =>
persona.displayName.trim().toLocaleLowerCase() === target &&
requestTargetsEditablePersona(persona),
);
}, [personasQuery.data, request]);
const currentPersona =
matchingPersonas.length === 1 ? matchingPersonas[0] : undefined;
const isPending =
createPersonaMutation.isPending ||
updatePersonaMutation.isPending ||
createAgentMutation.isPending;
function assertAgentCanActFromOrigin(channelId: string) {
const targetChannel = (channelsQuery.data ?? []).find(
(channel) => channel.id === channelId,
);
const requestingPubkey = sourceAgentPubkey.current?.toLowerCase();
if (
!targetChannel?.isMember ||
!requestingPubkey ||
!targetChannel.memberPubkeys.some(
(pubkey) => pubkey.toLowerCase() === requestingPubkey,
)
) {
throw new Error(
"An agent can only manage agents from a channel you both belong to.",
);
}
}
async function submitCreate(
input: CreatePersonaInput | UpdatePersonaInput,
intent: AgentCreateIntent,
backendIntent: BackendIntent | null,
): Promise<boolean> {
if (request?.action !== "create" || "id" in input) {
return false;
}
setError(null);
try {
assertAgentCanActFromOrigin(request.request.channelId);
const runtimes = await availableRuntimesForStart(runtimesQuery);
const runtime = runtimes.find(
(candidate) => candidate.id === input.runtime,
);
if (!runtime) {
throw new Error("Choose an available runtime for this agent.");
}
const avatarUrl = await resolveManagedAgentAvatarUrl(
input.avatarUrl,
undefined,
runtime.avatarUrl,
);
const persona = await mintDefinitionWithPreflight(
intent === "definition_start" ? backendIntent : null,
meshPrepareRelayMeshClient,
() =>
createPersonaMutation.mutateAsync({
...input,
avatarUrl,
}),
);
if (intent === "definition_start") {
const created = await createAgentMutation.mutateAsync(
await buildInstanceInputForDefinition(
persona,
runtime,
undefined,
backendIntent ?? undefined,
),
);
if (created.spawnError) throw new Error(created.spawnError);
const targetChannel = (channelsQuery.data ?? []).find(
(channel) => channel.id === request.request.channelId,
);
await createdAgentAttachment.presentCreatedAgent(created, {
id: request.request.channelId,
name: targetChannel?.name ?? "this channel",
});
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
dismiss();
return true;
} catch (cause) {
setError(
cause instanceof Error ? cause.message : "Could not save this agent.",
);
return false;
}
}
async function submitUpdate(input: CreatePersonaInput | UpdatePersonaInput) {
if (request?.action !== "update" || !("id" in input)) {
return false;
}
setError(null);
try {
assertAgentCanActFromOrigin(request.request.channelId);
await updatePersonaMutation.mutateAsync(input);
await Promise.all([
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
dismiss();
return true;
} catch (cause) {
setError(
cause instanceof Error ? cause.message : "Could not save this agent.",
);
return false;
}
}
function dismiss() {
pendingRequestId.current = null;
sourceAgentPubkey.current = null;
setRequest(null);
}
const createInitialValues = React.useMemo(
() =>
request?.action === "create" ? createInputFromRequest(request) : null,
[request],
);
const editInitialValues = React.useMemo(() => {
if (request?.action !== "update" || !currentPersona) return null;
return updateInputFromRequest(
request,
editPersonaDialogState(currentPersona)
.initialValues as UpdatePersonaInput,
);
}, [currentPersona, request]);
const editError = React.useMemo(() => {
if (request?.action !== "update") return error;
if (error) return error;
if (matchingPersonas.length > 1) {
return "More than one personal agent has that name. Rename it in Agents, then ask the agent again.";
}
if (!currentPersona) {
return "Agents can only update a personal agent profile by its current name.";
}
return null;
}, [currentPersona, error, matchingPersonas.length, request]);
return {
request,
createInitialValues,
editInitialValues,
editError,
error,
...createdAgentAttachment,
isPending,
runtimes: runtimesQuery.data ?? [],
runtimesLoading: runtimesQuery.isLoading,
submitCreate,
submitUpdate,
dismiss,
};
}
@@ -0,0 +1,82 @@
import * as React from "react";
import { attachManagedAgentToChannel } from "./channelAgents";
import type { AgentChannelAttachmentFailure } from "./channelAttachmentFailure";
import type { Channel, CreateManagedAgentResponse } from "@/shared/api/types";
type TargetChannel = Pick<Channel, "id" | "name">;
/**
* Keeps agent creation successful even when the follow-up channel attachment
* fails, and retries only that attachment rather than recreating the agent.
*/
export function useCreatedAgentChannelAttachment() {
const [createdAgent, setCreatedAgent] =
React.useState<CreateManagedAgentResponse | null>(null);
const [attachmentFailure, setAttachmentFailure] =
React.useState<AgentChannelAttachmentFailure | null>(null);
const targetChannelRef = React.useRef<TargetChannel | null>(null);
const [isRetryingAttachment, setIsRetryingAttachment] = React.useState(false);
async function attach(
created: CreateManagedAgentResponse,
targetChannel: TargetChannel,
) {
targetChannelRef.current = targetChannel;
try {
const attached = await attachManagedAgentToChannel(targetChannel.id, {
agent: created.agent,
role: "bot",
ensureRunning: true,
});
created.agent = attached.agent;
targetChannelRef.current = null;
setAttachmentFailure(null);
} catch (cause) {
setAttachmentFailure({
channelName: targetChannel.name,
error: cause instanceof Error ? cause.message : "Failed to add agent.",
});
}
}
async function presentCreatedAgent(
created: CreateManagedAgentResponse,
targetChannel?: TargetChannel | null,
) {
setAttachmentFailure(null);
targetChannelRef.current = null;
if (!created.spawnError && targetChannel) {
await attach(created, targetChannel);
}
setCreatedAgent({ ...created });
}
async function retryAttachment() {
const targetChannel = targetChannelRef.current;
if (!createdAgent || !targetChannel || isRetryingAttachment) return;
setIsRetryingAttachment(true);
try {
await attach(createdAgent, targetChannel);
setCreatedAgent({ ...createdAgent });
} finally {
setIsRetryingAttachment(false);
}
}
function dismissCreatedAgent() {
setCreatedAgent(null);
setAttachmentFailure(null);
targetChannelRef.current = null;
}
return {
attachmentFailure,
createdAgent,
dismissCreatedAgent,
isRetryingAttachment,
presentCreatedAgent,
retryAttachment,
};
}
@@ -1,4 +1,4 @@
import { AlertTriangle, ChevronDown } from "lucide-react";
import { AlertTriangle } from "lucide-react";
import * as React from "react";
import {
@@ -7,69 +7,28 @@ import {
useTeamsQuery,
type CreateChannelManagedAgentResult,
} from "@/features/agents/hooks";
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 {
AcpRuntime,
BackendProviderCandidate,
BackendProviderProbeResult,
ManagedAgentBackend,
RespondToMode,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Dialog } from "@/shared/ui/dialog";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
coerceConfigValues,
ProviderConfigFields,
} from "@/features/agents/ui/ProviderConfigFields";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { useEffectiveRuntimes } from "@/features/channels/ui/useEffectiveRuntimes";
import { getActivePersonas } from "@/features/agents/lib/catalog";
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { getUsableTeams } from "@/features/agents/lib/teamPersonas";
import { useLastRuntime } from "@/features/agents/lib/useLastRuntime";
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection";
import { AddChannelBotTeamsSection } from "@/features/channels/ui/AddChannelBotTeamsSection";
import { useInChannelPersonaIds } from "@/features/channels/ui/useInChannelPersonaIds";
import type { AcpRuntime } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
type AddChannelBotDialogProps = {
backendProviders?: BackendProviderCandidate[];
backendProvidersLoading?: boolean;
channelId: string | null;
open: boolean;
providers: AcpRuntime[];
providersErrorMessage?: string | null;
providersLoading?: boolean;
onAdded?: (result: CreateChannelManagedAgentResult) => void;
onCreateAgent: () => void;
onOpenChange: (open: boolean) => void;
};
const RUNTIME_NONE_SENTINEL = "__none__";
function defaultBotName(runtime: AcpRuntime | null) {
if (!runtime) {
return "";
}
const normalizedId = runtime.id.trim().toLowerCase();
if (normalizedId.length > 0) {
return normalizedId;
}
return runtime.label.trim().toLowerCase() || "agent";
}
function toggleValue(values: readonly string[], value: string) {
return values.includes(value)
? values.filter((candidate) => candidate !== value)
@@ -94,17 +53,15 @@ function formatBatchFailureSummary(
}
export function AddChannelBotDialog({
backendProviders,
backendProvidersLoading,
channelId,
open,
providers,
providersErrorMessage,
providersLoading = false,
onAdded,
onCreateAgent,
onOpenChange,
}: AddChannelBotDialogProps) {
const { setLastRuntime } = useLastRuntime();
const personasQuery = usePersonasQuery();
const teamsQuery = useTeamsQuery();
const inChannelPersonaIds = useInChannelPersonaIds(
@@ -120,266 +77,93 @@ export function AddChannelBotDialog({
() => getUsableTeams(teamsQuery.data ?? [], personas),
[personas, teamsQuery.data],
);
const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("");
const [selectedPersonaIds, setSelectedPersonaIds] = React.useState<string[]>(
[],
);
const [includeGeneric, setIncludeGeneric] = React.useState(false);
const [customName, setCustomName] = React.useState("");
const [customPrompt, setCustomPrompt] = React.useState("");
const [hasEditedCustomName, setHasEditedCustomName] = React.useState(false);
const [submissionNotice, setSubmissionNotice] = React.useState<string | null>(
null,
);
const [submissionError, setSubmissionError] = React.useState<string | null>(
null,
);
const [respondTo, setRespondTo] = React.useState<RespondToMode>("owner-only");
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
[],
);
const [forceNewInstance, setForceNewInstance] = React.useState(false);
const resolvedBackendProviders = backendProviders ?? [];
const resolvedBackendProvidersLoading = backendProvidersLoading ?? false;
const [runOn, setRunOn] = React.useState<"local" | string>("local");
const [providerConfig, setProviderConfig] = React.useState<
Record<string, string>
>({});
const [probedProvider, setProbedProvider] =
React.useState<BackendProviderProbeResult | null>(null);
const [probeError, setProbeError] = React.useState<string | null>(null);
const selectedRuntime = React.useMemo(
() =>
selectedRuntimeId
? (providers.find((runtime) => runtime.id === selectedRuntimeId) ??
null)
: null,
[providers, selectedRuntimeId],
);
const isOverrideActive = selectedRuntime !== null;
const selectedPersonas = React.useMemo(
() => personas.filter((persona) => selectedPersonaIds.includes(persona.id)),
[personas, selectedPersonaIds],
);
const selectedCount = selectedPersonas.length + (includeGeneric ? 1 : 0);
const reusableAgent = useReusableAgentDetection(
channelId,
open && channelId !== null,
selectedRuntime ?? providers[0] ?? null,
selectedPersonas,
includeGeneric,
customPrompt,
);
const { runtimeWarnings, effectiveRuntimes } = useEffectiveRuntimes(
personas,
selectedPersonas,
providers,
selectedRuntime,
isOverrideActive,
);
const isProviderMode = runOn !== "local";
const selectedBackendProvider = React.useMemo(
() => resolvedBackendProviders.find((p) => p.id === runOn) ?? null,
[resolvedBackendProviders, runOn],
);
const providerConfigComplete = React.useMemo(() => {
if (!isProviderMode || !probedProvider?.config_schema) return true;
const schema = probedProvider.config_schema as Record<string, unknown>;
const required: string[] = (schema?.required as string[] | undefined) ?? [];
return required.every(
(key) => (providerConfig[key] ?? "").trim().length > 0,
);
}, [isProviderMode, probedProvider, providerConfig]);
React.useEffect(() => {
if (!selectedRuntime || hasEditedCustomName) {
return;
}
setCustomName(defaultBotName(selectedRuntime));
}, [hasEditedCustomName, selectedRuntime]);
React.useEffect(() => {
setSelectedPersonaIds((current) =>
current.filter((id) => personas.some((persona) => persona.id === id)),
current.filter(
(id) =>
personas.some((persona) => persona.id === id) &&
!inChannelPersonaIds.has(id),
),
);
}, [personas]);
React.useEffect(() => {
if (!isProviderMode || !selectedBackendProvider) {
setProbedProvider(null);
setProbeError(null);
return;
}
let cancelled = false;
setProbeError(null);
setProbedProvider(null);
probeBackendProvider(selectedBackendProvider.binaryPath)
.then((result) => {
if (!cancelled) {
setProbedProvider(result);
if (result.config_schema) {
const props =
(result.config_schema as Record<string, unknown>)?.properties ??
{};
const defaults: Record<string, string> = {};
for (const [key, prop] of Object.entries(props) as [
string,
Record<string, unknown>,
][]) {
if (prop.default != null) {
defaults[key] = String(prop.default);
}
}
setProviderConfig(defaults);
}
}
})
.catch((err: unknown) => {
if (!cancelled) {
setProbeError(err instanceof Error ? err.message : String(err));
}
});
return () => {
cancelled = true;
};
}, [isProviderMode, selectedBackendProvider]);
}, [inChannelPersonaIds, personas]);
function reset() {
setSelectedRuntimeId("");
setSelectedPersonaIds([]);
setIncludeGeneric(false);
setCustomName(providers[0] ? defaultBotName(providers[0]) : "");
setCustomPrompt("");
setHasEditedCustomName(false);
setSubmissionNotice(null);
setSubmissionError(null);
setRunOn("local");
setProviderConfig({});
setProbedProvider(null);
setProbeError(null);
setRespondTo("owner-only");
setRespondToAllowlist([]);
setForceNewInstance(false);
createBotsMutation.reset();
}
function handleOpenChange(next: boolean) {
if (!next) {
reset();
}
if (!next) reset();
onOpenChange(next);
}
function handleCreateAgent() {
handleOpenChange(false);
onCreateAgent();
}
function handleToggleTeam(personaIds: string[]) {
const addableIds = personaIds.filter(
(personaId) => !inChannelPersonaIds.has(personaId),
);
setSelectedPersonaIds((current) => {
const allSelected = personaIds.every((id) => current.includes(id));
const allSelected = addableIds.every((id) => current.includes(id));
if (allSelected) {
return current.filter((id) => !personaIds.includes(id));
return current.filter((id) => !addableIds.includes(id));
}
const merged = new Set([...current, ...personaIds]);
return [...merged];
return [...new Set([...current, ...addableIds])];
});
setSubmissionNotice(null);
setSubmissionError(null);
}
function handleRunOnChange(value: string) {
setRunOn(value);
setProviderConfig({});
setProbedProvider(null);
setProbeError(null);
setSubmissionNotice(null);
setSubmissionError(null);
}
async function handleSubmit() {
if (providers.length === 0 || selectedCount === 0) {
return;
}
if (providers.length === 0 || selectedPersonas.length === 0) return;
const backend: ManagedAgentBackend = isProviderMode
? {
type: "provider",
id: runOn,
config: coerceConfigValues(
providerConfig,
probedProvider?.config_schema,
),
}
: { type: "local" };
const respondToFields =
respondTo !== "owner-only"
? {
respondTo,
respondToAllowlist:
respondTo === "allowlist" ? respondToAllowlist : undefined,
}
: {};
const inputs = [
...(includeGeneric
? [
{
runtime: selectedRuntime ?? providers[0],
name: customName,
systemPrompt: customPrompt,
role: "bot" as const,
backend,
forceNewInstance,
...respondToFields,
},
]
: []),
...selectedPersonas.map((persona) => {
const effectiveFallback = selectedRuntime ?? providers[0] ?? null;
const resolved = resolvePersonaRuntime(
persona.runtime,
providers,
effectiveFallback,
isOverrideActive,
);
return {
runtime: resolved.runtime ?? effectiveFallback ?? providers[0],
name: persona.displayName,
personaId: persona.id,
// A deliberate runtime-selector pick overrides the persona; a Case-3
// fallback (persona runtime not installed) is NOT a pin. `isOverridden`
// alone can't tell them apart, so thread the explicit user intent.
harnessOverride: isOverrideActive,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
role: "bot" as const,
backend,
forceNewInstance,
...respondToFields,
};
}),
];
const inputs = selectedPersonas.map((persona) => {
const resolved = resolvePersonaRuntime(
persona.runtime,
providers,
providers[0] ?? null,
false,
);
return {
runtime: resolved.runtime ?? providers[0],
name: persona.displayName,
personaId: persona.id,
harnessOverride: false,
systemPrompt: persona.systemPrompt,
avatarUrl: persona.avatarUrl ?? undefined,
model: persona.model ?? undefined,
role: "bot" as const,
backend: { type: "local" as const },
};
});
setSubmissionNotice(null);
setSubmissionError(null);
try {
const result = await createBotsMutation.mutateAsync(inputs);
if (result.failures.length === 0) {
if (result.successes[0]) {
onAdded?.(result.successes[0]);
}
if (result.successes[0]) onAdded?.(result.successes[0]);
handleOpenChange(false);
return;
}
@@ -392,10 +176,6 @@ export function AddChannelBotDialog({
setSelectedPersonaIds((current) =>
current.filter((personaId) => failedPersonaIds.has(personaId)),
);
setIncludeGeneric(
result.failures.some((failure) => failure.kind === "generic"),
);
if (result.successes.length > 0) {
setSubmissionNotice(
`Added ${result.successes.length} ${formatAgentCountLabel(
@@ -403,51 +183,31 @@ export function AddChannelBotDialog({
)}.`,
);
}
setSubmissionError(formatBatchFailureSummary(result.failures));
} catch {
// The mutation error is rendered inline.
}
}
// Allowlist mode requires at least one entry, mirroring the harness's own
// validation. If we let it through empty, the agent crash-loops at startup
// with a config error.
const respondToValid =
respondTo !== "allowlist" || respondToAllowlist.length > 0;
const canSubmit =
(selectedRuntime !== null || providers.length > 0) &&
selectedCount > 0 &&
(!includeGeneric || customName.trim().length > 0) &&
respondToValid &&
!(isProviderMode && !probedProvider) &&
providerConfigComplete &&
providers.length > 0 &&
selectedPersonas.length > 0 &&
!providersLoading &&
!(isProviderMode && resolvedBackendProvidersLoading) &&
!createBotsMutation.isPending;
const canChooseProvider =
providers.length > 0 && !providersLoading && !createBotsMutation.isPending;
const canToggleSelections = !createBotsMutation.isPending;
const runtimeTriggerLabel = providersLoading
? "Loading runtimes..."
: providers.length === 0
? "No runtimes found"
: (selectedRuntime?.label ?? "Use agent defaults");
const addButtonLabel = createBotsMutation.isPending
? selectedCount > 1
? `Adding ${selectedCount}...`
: "Adding..."
: selectedCount > 1
? `Add ${selectedCount} agents`
? selectedPersonas.length > 1
? `Adding ${selectedPersonas.length}…`
: "Adding…"
: selectedPersonas.length > 1
? `Add ${selectedPersonas.length} agents`
: "Add agent";
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<ChooserDialogContent
className="max-w-3xl"
className="max-w-xl"
data-testid="add-channel-bot-dialog"
description="Select any combination of saved agents, or turn on Generic for a one-off custom agent."
description="Choose from your agents, or create a new one."
footer={
<>
<Button
@@ -475,126 +235,11 @@ export function AddChannelBotDialog({
scrollAreaTestId="add-channel-bot-dialog-scroll-area"
title="Add agents"
>
{resolvedBackendProviders.length > 0 ? (
<div className="space-y-1.5">
<div className="text-sm font-medium">Run on</div>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
disabled={createBotsMutation.isPending}
onChange={(e) => handleRunOnChange(e.target.value)}
value={runOn}
>
<option value="local">This computer</option>
{resolvedBackendProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</select>
</div>
) : null}
{isProviderMode && selectedBackendProvider ? (
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">
This provider at{" "}
<span className="font-mono font-medium">
{selectedBackendProvider.binaryPath}
</span>{" "}
will receive your agent&apos;s private key. Only use providers
from trusted sources.
</p>
</div>
) : null}
{probeError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Could not probe provider: {probeError}
</p>
) : null}
{isProviderMode && probedProvider?.config_schema ? (
<ProviderConfigFields
config={providerConfig}
onChange={setProviderConfig}
schema={probedProvider.config_schema}
/>
) : null}
<div className="space-y-1.5">
<div className="text-sm font-medium">Runtime</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-9 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-3 text-sm font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={!canChooseProvider}
size="default"
type="button"
variant="ghost"
>
<span className="truncate">{runtimeTriggerLabel}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-40"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuRadioGroup
onValueChange={(id) => {
if (id === RUNTIME_NONE_SENTINEL) {
setSelectedRuntimeId("");
} else {
setSelectedRuntimeId(id);
setLastRuntime(id);
}
}}
value={selectedRuntime?.id ?? RUNTIME_NONE_SENTINEL}
>
<DropdownMenuRadioItem value={RUNTIME_NONE_SENTINEL}>
Use agent defaults
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
{providers.map((provider) => (
<DropdownMenuRadioItem key={provider.id} value={provider.id}>
{provider.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<p className="text-xs text-muted-foreground">
{isOverrideActive
? "All agents will use this runtime, overriding their configured preferences."
: "Each agent uses its preferred runtime. Choose a runtime above to override all."}
</p>
</div>
{teams.length > 0 ? (
<AddChannelBotTeamsSection
canToggleSelections={canToggleSelections}
inChannelPersonaIds={inChannelPersonaIds}
isLoading={teamsQuery.isLoading}
onToggleTeam={handleToggleTeam}
personas={personas}
selectedPersonaIds={selectedPersonaIds}
teams={teams}
/>
) : null}
<AddChannelBotPersonasSection
canToggleSelections={canToggleSelections}
effectiveRuntimes={effectiveRuntimes}
canToggleSelections={!createBotsMutation.isPending}
inChannelPersonaIds={inChannelPersonaIds}
includeGeneric={includeGeneric}
isLoading={personasQuery.isLoading}
onToggleGeneric={() => {
setIncludeGeneric((current) => !current);
setSubmissionNotice(null);
setSubmissionError(null);
}}
onCreateAgent={handleCreateAgent}
onTogglePersona={(personaId) => {
setSelectedPersonaIds((current) => toggleValue(current, personaId));
setSubmissionNotice(null);
@@ -604,84 +249,49 @@ export function AddChannelBotDialog({
selectedPersonaIds={selectedPersonaIds}
/>
{includeGeneric ? (
<AddChannelBotGenericSection
disabled={createBotsMutation.isPending}
name={customName}
onNameChange={(value) => {
setHasEditedCustomName(true);
setCustomName(value);
}}
onPromptChange={setCustomPrompt}
prompt={customPrompt}
{teams.length > 0 ? (
<AddChannelBotTeamsSection
canToggleSelections={!createBotsMutation.isPending}
inChannelPersonaIds={inChannelPersonaIds}
isLoading={teamsQuery.isLoading}
onToggleTeam={handleToggleTeam}
personas={personas}
selectedPersonaIds={selectedPersonaIds}
teams={teams}
/>
) : null}
{reusableAgent ? (
<div className="pt-2">
<AddChannelBotReuseGuard
disabled={createBotsMutation.isPending}
forceNew={forceNewInstance}
onForceNewChange={setForceNewInstance}
reusableAgent={reusableAgent}
/>
</div>
) : null}
{selectedCount > 0 ? (
<CreateAgentRespondToField
allowlist={respondToAllowlist}
disabled={createBotsMutation.isPending}
mode={respondTo}
onAllowlistChange={setRespondToAllowlist}
onModeChange={setRespondTo}
/>
) : null}
{selectedCount === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/15 px-4 py-4 text-sm text-muted-foreground">
Pick one or more agents, or enable Generic to add a custom agent.
{providers.length === 0 && !providersLoading ? (
<div className="flex gap-3 rounded-lg border border-warning/30 bg-warning-bg px-4 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">
Install an agent runtime before adding an agent to this channel.
</p>
</div>
) : null}
{providersErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersErrorMessage}
</p>
) : null}
{runtimeWarnings.length > 0
? runtimeWarnings.map((warning) => (
<div
className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3"
key={warning}
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">{warning}</p>
</div>
))
: null}
{personasQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{personasQuery.error.message}
</p>
) : null}
{submissionNotice ? (
<p className="rounded-2xl border border-border/70 bg-muted/25 px-4 py-3 text-sm text-foreground">
<p className="rounded-lg bg-muted px-4 py-3 text-sm text-foreground">
{submissionNotice}
</p>
) : null}
{submissionError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{submissionError}
</p>
) : null}
{createBotsMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{createBotsMutation.error.message}
</p>
) : null}
@@ -1,223 +1,180 @@
import { Bot, Check } from "lucide-react";
import { Check, Plus } from "lucide-react";
import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { promptPreview } from "@/shared/lib/promptPreview";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
type SelectionChipButtonProps = {
avatarUrl?: string | null;
disabled: boolean;
label: string;
onClick: () => void;
selected: boolean;
children: React.ReactNode;
};
function SelectionChipButton({
avatarUrl,
disabled,
label,
onClick,
function AgentRow({
persona,
selected,
children,
}: SelectionChipButtonProps) {
const showAvatar = avatarUrl !== undefined;
disabled,
inChannel,
onToggle,
}: {
persona: AgentPersona;
selected: boolean;
disabled: boolean;
inChannel: boolean;
onToggle: () => void;
}) {
return (
<button
aria-pressed={selected}
aria-pressed={inChannel ? undefined : selected}
className={cn(
"inline-flex min-h-9 items-center gap-2 rounded-full border py-1.5 text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
showAvatar ? "pl-1.5 pr-3" : "px-3",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
disabled && "cursor-not-allowed opacity-50",
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
inChannel
? "cursor-default text-muted-foreground"
: selected
? "bg-accent text-accent-foreground"
: "hover:bg-accent/60",
disabled && !inChannel && "cursor-not-allowed opacity-50",
)}
disabled={disabled}
onClick={onClick}
disabled={disabled || inChannel}
onClick={onToggle}
type="button"
>
{showAvatar ? (
<ProfileAvatar
avatarUrl={avatarUrl}
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-9 w-9 shrink-0 text-xs"
iconClassName="h-5 w-5"
label={persona.displayName}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{persona.displayName}
</span>
{inChannel ? (
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
<Check className="h-4 w-4" />
In channel
</span>
) : (
<span
aria-hidden
className={cn(
"h-6 w-6 text-2xs",
"flex h-5 w-5 shrink-0 items-center justify-center rounded border",
selected
? "bg-primary/20 text-primary ring-1 ring-primary/20"
: "bg-background/80 text-muted-foreground ring-1 ring-border/70",
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background",
)}
iconClassName="h-4 w-4"
label={label}
/>
) : null}
{children}
>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
</span>
)}
</button>
);
}
function RuntimeBadge({
label,
isOverridden,
}: {
label: string;
isOverridden: boolean;
}) {
function CreateAgentRow({ onCreateAgent }: { onCreateAgent: () => void }) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-2xs font-medium leading-none",
isOverridden
? "bg-warning/15 text-warning"
: "bg-muted/60 text-muted-foreground",
)}
<button
className="flex w-full items-center gap-3 rounded-lg border border-border bg-card px-3 py-3 text-left transition-colors hover:bg-accent/50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="add-channel-create-agent"
onClick={onCreateAgent}
type="button"
>
{label}
</span>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Plus className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
Create a new agent
</span>
<span className="block text-xs text-muted-foreground">
Give it a name, purpose, and instructions.
</span>
</span>
</button>
);
}
type AddChannelBotPersonasSectionProps = {
canToggleSelections: boolean;
/** Map of personaId → effective runtime label for badge display */
effectiveRuntimes?: ReadonlyMap<
string,
{ label: string; isOverridden: boolean }
>;
inChannelPersonaIds?: ReadonlySet<string>;
includeGeneric: boolean;
isLoading: boolean;
onToggleGeneric: () => void;
onCreateAgent?: () => void;
onTogglePersona: (personaId: string) => void;
personas: AgentPersona[];
selectedPersonaIds: readonly string[];
/** Whether to show the "Generic" chip. Defaults to true. */
// Legacy no-op props retained for the channel-template selector. Generic
// remains supported there but is no longer exposed by channel add flows.
includeGeneric?: boolean;
onToggleGeneric?: () => void;
showGeneric?: boolean;
};
export function AddChannelBotPersonasSection({
canToggleSelections,
effectiveRuntimes,
inChannelPersonaIds,
includeGeneric,
isLoading,
onToggleGeneric,
onCreateAgent,
onTogglePersona,
personas,
selectedPersonaIds,
showGeneric = true,
}: AddChannelBotPersonasSectionProps) {
const available = personas.filter(
(persona) => !inChannelPersonaIds?.has(persona.id),
);
const inChannel = personas.filter((persona) =>
inChannelPersonaIds?.has(persona.id),
);
return (
<div className="space-y-3">
<div className="space-y-3">
<div>
<div className="text-sm font-medium">Agents</div>
<p className="text-xs text-muted-foreground">
Toggle as many as you want. Each selected agent is added to the
channel. Hover an agent to preview its role.
</p>
</div>
<div className="space-y-4">
{onCreateAgent && available.length === 0 ? (
<CreateAgentRow onCreateAgent={onCreateAgent} />
) : null}
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap gap-2">
{showGeneric ? (
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectionChipButton
disabled={!canToggleSelections}
label="Generic"
onClick={onToggleGeneric}
selected={includeGeneric}
>
<Bot
className={cn(
"h-4 w-4",
includeGeneric ? "text-primary" : "text-current",
)}
/>
Generic
</SelectionChipButton>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-left">
Add one custom agent with a channel-specific name and prompt.
</TooltipContent>
</Tooltip>
) : null}
{personas.map((persona) => {
const isSelected = selectedPersonaIds.includes(persona.id);
const isInChannel = inChannelPersonaIds?.has(persona.id) ?? false;
const runtimeBadge = effectiveRuntimes?.get(persona.id);
return (
<Tooltip key={persona.id}>
<TooltipTrigger asChild>
<div>
<SelectionChipButton
avatarUrl={persona.avatarUrl}
disabled={!canToggleSelections || isInChannel}
label={persona.displayName}
onClick={() => onTogglePersona(persona.id)}
selected={isSelected}
>
{persona.displayName}
{runtimeBadge ? (
<RuntimeBadge {...runtimeBadge} />
) : null}
{isInChannel ? (
<span
className={cn(
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-2xs font-medium leading-none",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted/60 text-muted-foreground",
)}
>
<Check className="h-4 w-4" />
In channel
</span>
) : null}
</SelectionChipButton>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-left">
<div className="space-y-2">
<div className="flex items-center gap-2">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-7 w-7 text-2xs bg-primary-foreground/20 text-primary-foreground"
iconClassName="h-4 w-4"
label={persona.displayName}
/>
<p className="font-medium">{persona.displayName}</p>
</div>
{isInChannel ? (
<p className="text-2xs font-medium text-emerald-300">
✓ Already in this channel
</p>
) : null}
<p className="text-2xs text-primary-foreground">
{promptPreview(persona.systemPrompt)}
</p>
</div>
</TooltipContent>
</Tooltip>
);
})}
{isLoading ? (
<p className="px-3 text-sm text-muted-foreground">
Loading your agents…
</p>
) : null}
{!isLoading && available.length > 0 ? (
<div className="space-y-1">
<div className="px-3 pb-1 text-xs font-medium text-muted-foreground">
Your agents
</div>
</TooltipProvider>
{available.map((persona) => (
<AgentRow
disabled={!canToggleSelections}
inChannel={false}
key={persona.id}
onToggle={() => onTogglePersona(persona.id)}
persona={persona}
selected={selectedPersonaIds.includes(persona.id)}
/>
))}
</div>
) : null}
{isLoading ? (
<p className="text-xs text-muted-foreground">Loading agents...</p>
) : null}
</div>
{!isLoading && available.length === 0 && inChannel.length > 0 ? (
<p className="px-3 text-sm text-muted-foreground">
All of your agents are already in this channel.
</p>
) : null}
{onCreateAgent && available.length > 0 ? (
<CreateAgentRow onCreateAgent={onCreateAgent} />
) : null}
{!isLoading && inChannel.length > 0 ? (
<div className="space-y-1 border-t border-border pt-3">
<div className="px-3 pb-1 text-xs font-medium text-muted-foreground">
In this channel
</div>
{inChannel.map((persona) => (
<AgentRow
disabled
inChannel
key={persona.id}
onToggle={() => undefined}
persona={persona}
selected={false}
/>
))}
</div>
) : null}
</div>
);
}
@@ -6,10 +6,10 @@ import { HuddleIndicator } from "@/features/huddle/components/HuddleIndicator";
import { buildHuddleChannelName } from "@/features/huddle/lib/huddleChannelName";
import {
useAvailableAcpRuntimes,
useBackendProvidersQuery,
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { canStartHuddleInChannel } from "@/features/channels/lib/huddleAvailability";
import type { Channel } from "@/shared/api/types";
@@ -58,7 +58,6 @@ export function ChannelMembersBar({
const queryClient = useQueryClient();
const membersQuery = useChannelMembersQuery(channel.id);
const providersQuery = useAvailableAcpRuntimes();
const backendProvidersQuery = useBackendProvidersQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
const members = membersQuery.data ?? [];
@@ -205,9 +204,13 @@ export function ChannelMembersBar({
{controls}
<AddChannelBotDialog
backendProviders={backendProvidersQuery.data ?? []}
backendProvidersLoading={backendProvidersQuery.isLoading}
channelId={channel.id}
onCreateAgent={() => {
requestOpenCreateAgent({
channelId: channel.id,
channelName: channel.name,
});
}}
onOpenChange={setIsAddBotOpen}
open={isAddBotOpen}
providers={providers}
@@ -437,6 +437,19 @@ export const ChannelPane = React.memo(function ChannelPane({
const actions = [];
if (isWelcomeChannel(activeChannel)) {
if (onAddAgent) {
actions.push({
icon: <Bot aria-hidden className="h-6 w-6" />,
label: "Create an agent",
onClick: () =>
onAddAgent({
beforeSend: () =>
messageTimelineRef.current?.scrollToBottomOnNextUpdate(),
}),
testId: "welcome-intro-action-create-agent",
});
}
if (onCreateChannel) {
actions.push({
icon: <Plus aria-hidden className="h-6 w-6" />,
@@ -446,15 +459,6 @@ export const ChannelPane = React.memo(function ChannelPane({
});
}
if (onAddAgent) {
actions.push({
icon: <Bot aria-hidden className="h-6 w-6" />,
label: "Create a custom agent",
onClick: onAddAgent,
testId: "welcome-intro-action-create-agent",
});
}
return {
actions,
channelKindLabel: "private welcome channel",
@@ -70,7 +70,7 @@ export type ChannelPaneProps = {
onCloseChannelManagement?: () => void;
onChannelManagementDeleted?: () => void;
onCloseProfilePanel: () => void;
onAddAgent?: () => void;
onAddAgent?: (options?: { beforeSend?: () => void }) => void;
onCreateChannel?: () => void;
onCloseThread: () => void;
onDelete?: (message: TimelineMessage) => void;
@@ -15,6 +15,7 @@ import {
import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState";
import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader";
import { ChannelPane } from "@/features/channels/ui/ChannelScreenLazyViews";
import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog";
import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent";
import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
import {
@@ -23,6 +24,8 @@ import {
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide";
import { useWelcomeAgentCreate } from "@/features/channels/useWelcomeAgentCreate";
import {
mergeMessages,
useChannelMessagesQuery,
@@ -144,9 +147,7 @@ export function ChannelScreen({
string | null
>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
// Thread panel state is URL-backed, but router navigation is deferred out
// of the click handler; a tiny optimistic override lets the auxiliary pane
// open/close in the urgent render before the URL-backed state catches up.
// URL-backed thread state catches up after navigation; this override keeps urgent open/close renders responsive.
const [optimisticOpenThreadHeadId, setOptimisticOpenThreadHeadId] =
React.useState<string | null | undefined>(undefined);
const clearOptimisticThreadOverride = React.useCallback(() => {
@@ -232,26 +233,19 @@ export function ChannelScreen({
const deleteMessageMutation = useDeleteMessageMutation(activeChannel);
const editMessageMutation = useEditMessageMutation(activeChannel);
const joinChannelMutation = useJoinChannelMutation(activeChannelId);
const [findTargetEvents, setFindTargetEvents] = React.useState<RelayEvent[]>(
[],
);
const [findEvents, setFindEvents] = React.useState<RelayEvent[]>([]);
// biome-ignore lint/correctness/useExhaustiveDependencies: clear spliced find results exactly when the active channel changes.
React.useEffect(() => {
setFindTargetEvents([]);
setFindEvents([]);
}, [activeChannelId]);
const resolvedMessages = React.useMemo(() => {
const currentMessages = messagesQuery.data ?? [];
const extraEvents = [...targetMessageEvents, ...findTargetEvents];
const extraEvents = [...targetMessageEvents, ...findEvents];
if (!activeChannel || extraEvents.length === 0) {
return currentMessages;
}
return extraEvents.reduce(mergeMessages, currentMessages);
}, [
activeChannel,
findTargetEvents,
messagesQuery.data,
targetMessageEvents,
]);
}, [activeChannel, findEvents, messagesQuery.data, targetMessageEvents]);
const threadReplyEvents = threadRepliesQuery.data ?? EMPTY_RELAY_EVENTS;
const messageEventProfilePubkeys = React.useMemo(() => {
const events = [...resolvedMessages, ...threadReplyEvents];
@@ -283,6 +277,15 @@ export function ChannelScreen({
const channelMembers = channelMembersQuery.data;
const managedAgentsQuery = useManagedAgentsQuery();
const managedAgents = managedAgentsQuery.data ?? [];
const welcomeGuideAgent = React.useMemo(
() => pickWelcomeGuideAgent(managedAgents),
[managedAgents],
);
const welcomeAgentCreate = useWelcomeAgentCreate({
activeChannel,
currentIdentity,
welcomeGuideAgent,
});
const relayAgentsQuery = useRelayAgentsQuery();
const relayAgents = relayAgentsQuery.data ?? [];
const knownAgentPubkeys = React.useMemo(() => {
@@ -341,9 +344,6 @@ export function ChannelScreen({
relayAgents,
typingEntries,
});
// Observer ingestion (frame decryption + derived active-turn liveness) is
// owner-global — mounted once in AppShell via useAgentObserverIngestion —
// so this screen no longer mounts its own observer/turns bridges.
const messageProfiles = useMessageProfiles({
channelMembers,
currentProfile,
@@ -416,7 +416,7 @@ export function ChannelScreen({
);
const handleFindSearchHit = React.useCallback((hit: SearchHit) => {
const event = cacheSearchHitEvent(hit);
setFindTargetEvents((currentEvents) =>
setFindEvents((currentEvents) =>
currentEvents.some((currentEvent) => currentEvent.id === event.id)
? currentEvents
: [...currentEvents, event],
@@ -514,8 +514,6 @@ export function ChannelScreen({
: undefined,
[activeChannel, handleToggleReaction],
);
// The menu actions are typed (message) => void; the read-state handlers
// key off the message id (message + subtree). Adapt at the seam.
const handleMessageMarkUnread = React.useCallback(
(message: TimelineMessage) => handleMarkMessageUnread(message.id),
[handleMarkMessageUnread],
@@ -546,7 +544,11 @@ export function ChannelScreen({
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
? handleSendVideoReviewComment
: undefined;
const handleOpenAddBot = React.useCallback(() => setIsAddBotOpen(true), []);
const handleOpenAddBot = React.useCallback(
(options?: { beforeSend?: () => void }) =>
welcomeAgentCreate.openAddAgent(() => setIsAddBotOpen(true), options),
[welcomeAgentCreate],
);
const handleOpenMembersSidebar = React.useCallback(
() => setIsMembersSidebarOpen(true),
[],
@@ -570,9 +572,6 @@ export function ChannelScreen({
} = useChannelAgentSessions({
activeChannel,
activeChannelId,
// Loaded only once none of the three agent queries are in their initial
// fetch, so a channel with genuinely zero agents still auto-closes a stale
// agentSession param (a disabled query reports isLoading=false — fine).
agentsLoaded:
!channelMembersQuery.isLoading &&
!managedAgentsQuery.isLoading &&
@@ -602,10 +601,6 @@ export function ChannelScreen({
setThreadReplyTargetId,
setThreadScrollTargetId,
});
// `data !== undefined` is not "loaded" (the cache is seeded early by stale
// placeholders and the live subscription); wait for the history fetch to
// settle, latched per channel so a background refetch can't re-flip to the
// skeleton (the "skeleton bouncing up and down" on entry).
const settledChannelIdRef = React.useRef<string | null>(null);
const hasSettledThisChannel =
activeChannelId !== null && settledChannelIdRef.current === activeChannelId;
@@ -628,8 +623,6 @@ export function ChannelScreen({
timelineLoadingNow,
);
settledChannelIdRef.current = settledChannelId;
// Panel identity (thread/profile/agent session) lives in the URL search
// params and carries per history entry — only ephemeral targets reset here.
const resetComposerTargets = React.useCallback(
(_channelId: string | null) => {
setExpandedThreadReplyIds(new Set());
@@ -663,8 +656,6 @@ export function ChannelScreen({
});
React.useEffect(() => {
if (openThreadHeadId && !openThreadHeadMessage) {
// While the timeline is still loading (e.g. a reload restoring the
// thread param from the URL) the head simply hasn't arrived yet.
if (isTimelineLoading) {
return;
}
@@ -807,6 +798,15 @@ export function ChannelScreen({
return (
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
<WelcomeAgentCreateDialog
guideName={welcomeGuideAgent?.name ?? "your welcome guide"}
isSending={welcomeAgentCreate.isSending}
onCreateInChat={() => void welcomeAgentCreate.createInChat()}
onCreateManually={welcomeAgentCreate.createManually}
onOpenChange={welcomeAgentCreate.setIsOpen}
open={welcomeAgentCreate.isOpen}
sendError={welcomeAgentCreate.error}
/>
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
ref={channelContentRef}
@@ -0,0 +1,102 @@
import { MessageCircle, SlidersHorizontal } from "lucide-react";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
type WelcomeAgentCreateDialogProps = {
guideName: string;
isSending: boolean;
open: boolean;
sendError?: string | null;
onCreateInChat: () => void;
onCreateManually: () => void;
onOpenChange: (open: boolean) => void;
};
export function WelcomeAgentCreateDialog({
guideName,
isSending,
open,
sendError,
onCreateInChat,
onCreateManually,
onOpenChange,
}: WelcomeAgentCreateDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create an agent</DialogTitle>
<DialogDescription>
Start with a conversation, or set everything up yourself.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<button
className="flex w-full items-start gap-3 rounded-lg border border-primary bg-transparent p-4 text-left transition-colors hover:bg-primary/5 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
data-testid="welcome-create-agent-in-chat"
disabled={isSending}
onClick={onCreateInChat}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<MessageCircle className="h-5 w-5" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
Create with {guideName}
</span>
<span className="mt-0.5 block text-sm text-muted-foreground">
Talk through what you need. {guideName} will prepare a draft you
can review and edit.
</span>
</span>
</button>
<button
className="flex w-full items-start gap-3 rounded-lg p-4 text-left transition-colors hover:bg-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="welcome-create-agent-manually"
onClick={onCreateManually}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground">
<SlidersHorizontal className="h-5 w-5" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
Create manually
</span>
<span className="mt-0.5 block text-sm text-muted-foreground">
Fill in the agent’s name, instructions, and settings yourself.
</span>
</span>
</button>
</div>
{sendError ? (
<p className="text-sm text-destructive" role="alert">
{sendError}
</p>
) : null}
<div className="flex justify-end">
<Button
disabled={isSending}
onClick={() => onOpenChange(false)}
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,88 @@
import * as React from "react";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import { useSendMessageMutation } from "@/features/messages/hooks";
import { isWelcomeChannel } from "@/features/onboarding/welcome";
import type { Channel, Identity } from "@/shared/api/types";
type WelcomeGuideAgent = {
name: string;
pubkey: string;
};
/** Coordinates the Welcome channel's chat-first versus manual create choice. */
export function useWelcomeAgentCreate({
activeChannel,
currentIdentity,
welcomeGuideAgent,
}: {
activeChannel: Channel | null;
currentIdentity: Identity | null | undefined;
welcomeGuideAgent: WelcomeGuideAgent | null | undefined;
}) {
const sendMessageMutation = useSendMessageMutation(
activeChannel,
currentIdentity ?? undefined,
);
const [isOpen, setIsOpen] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const beforeSendRef = React.useRef<(() => void) | null>(null);
const openAddAgent = React.useCallback(
(openRegularPicker: () => void, options?: { beforeSend?: () => void }) => {
setError(null);
if (isWelcomeChannel(activeChannel)) {
beforeSendRef.current = options?.beforeSend ?? null;
setIsOpen(true);
return;
}
beforeSendRef.current = null;
openRegularPicker();
},
[activeChannel],
);
const createManually = React.useCallback(() => {
if (!activeChannel) return;
setIsOpen(false);
requestOpenCreateAgent({
channelId: activeChannel.id,
channelName: activeChannel.name,
});
}, [activeChannel]);
const createInChat = React.useCallback(async () => {
if (!activeChannel || !welcomeGuideAgent) {
setError(
"The welcome guide is unavailable. Create the agent manually instead.",
);
return;
}
setError(null);
try {
beforeSendRef.current?.();
await sendMessageMutation.mutateAsync({
channelId: activeChannel.id,
content: `@${welcomeGuideAgent.name}, help me create a new agent.`,
mentionPubkeys: [welcomeGuideAgent.pubkey],
});
setIsOpen(false);
} catch (cause) {
setError(
cause instanceof Error
? cause.message
: "Could not start the conversation.",
);
}
}, [activeChannel, sendMessageMutation, welcomeGuideAgent]);
return {
createInChat,
createManually,
error,
isOpen,
isSending: sendMessageMutation.isPending,
openAddAgent,
setIsOpen,
};
}
@@ -215,12 +215,13 @@ export function ProfileStep({
effect={transitionEffect}
transitionKey={`profile-${direction}`}
>
<div className="w-full max-w-[500px]">
<div className="w-full max-w-2xl">
<h1 className="text-3xl font-semibold text-foreground">
First, let's start with your name
What should we call you?
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Enter a nickname or whatever you want people to call you.
Pick the name people and agents will see in Buzz. You can change it
anytime.
</p>
</div>
@@ -240,7 +241,7 @@ export function ProfileStep({
aria-hidden="true"
className="buzz-onboarding-name-placeholder-caret h-[0.9em] w-0.5 rounded-full bg-primary"
/>
Name
Enter your name
</span>
</div>
) : null}
@@ -285,7 +286,7 @@ export function ProfileStep({
{isSaving ? (
<Spinner aria-label="Saving profile" className="h-4 w-4 border-2" />
) : (
"Next"
"Create an identity key"
)}
</Button>
+18
View File
@@ -127,6 +127,8 @@ type E2eConfig = {
agentListDelayMs?: number;
agentMemory?: RawAgentMemoryListing | Record<string, RawAgentMemoryListing>;
addChannelMembersDelayMs?: number;
/** Sequenced add-member failures. A string fails that call; null succeeds. */
addChannelMembersErrors?: (string | null)[];
channelMembersReadDelayMs?: number;
createManagedAgentDelayMs?: number;
channelsReadError?: string;
@@ -5866,6 +5868,21 @@ async function handleAddChannelMembers(
window.setTimeout(resolve, addChannelMembersDelayMs),
);
}
const configuredErrors = config?.mock?.addChannelMembersErrors;
if (configuredErrors && configuredErrors.length > 0) {
const index = Math.min(
addChannelMembersCallCount,
configuredErrors.length - 1,
);
addChannelMembersCallCount += 1;
const error = configuredErrors[index];
if (error) {
return {
added: [],
errors: args.pubkeys.map((pubkey) => ({ pubkey, error })),
};
}
}
const identity = getIdentity(config);
if (!identity) {
const channel = getMockChannel(args.channelId);
@@ -6488,6 +6505,7 @@ async function handleDiscoverAcpRuntimes(
// Per-page install call counter. Reset each test run because this module is
// re-evaluated via addInitScript, so the counter starts at 0 for every test.
let installCallCount = 0;
let addChannelMembersCallCount = 0;
// Per-page get_nsec call counter for sequenced error testing.
let nsecCallCount = 0;
+1 -1
View File
@@ -287,7 +287,7 @@ async function expectWelcomeView(page: Page) {
);
await expectIntroActionIconStackedAboveTitle(
page.getByTestId("welcome-intro-action-create-agent"),
"Create a custom agent",
"Create an agent",
);
await expect(page.getByTestId("message-composer")).toBeVisible();
await expect(page.getByTestId("welcome-composer-guide-banner")).toBeVisible();
@@ -0,0 +1,294 @@
import { expect, test, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, openCreateChannelDialog } from "../helpers/bridge";
const SHOTS = "test-results/welcome-agent-modal";
const FIZZ_PUBKEY = "f".repeat(64);
const SCOUT_PUBKEY = "a".repeat(64);
const EDITOR_PUBKEY = "b".repeat(64);
const scoutPersona = {
id: "personal:scout",
displayName: "Scout",
systemPrompt: "Research a topic, verify sources, and return a concise brief.",
};
const editorPersona = {
id: "personal:editor",
displayName: "Editor",
systemPrompt: "Turn rough writing into clear, confident prose.",
};
async function openChannel(page: Page, name: string) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId(`channel-${name}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(name);
}
async function readCommandLog(page: Page) {
return page.evaluate(() => {
const commands = (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__;
if (!commands) {
throw new Error("E2E bridge command log is not installed");
}
return commands;
});
}
function commandCount(commands: string[], command: string) {
return commands.filter((entry) => entry === command).length;
}
async function openAgentPicker(page: Page, channel = "random") {
await openChannel(page, channel);
await page.getByTestId("channel-intro-action-create-agent").click();
const dialog = page.getByTestId("add-channel-bot-dialog");
await expect(dialog).toBeVisible();
await waitForAnimations(page);
return dialog;
}
test.describe("welcome and channel agent entry points", () => {
test("welcome offers chat-first creation", async ({ page }) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
managedAgents: [
{
pubkey: FIZZ_PUBKEY,
name: "Fizz",
personaId: "builtin:fizz",
status: "running",
channelNames: ["Welcome"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill("Welcome");
await page
.getByTestId("create-channel-description")
.fill("A private channel for getting oriented in this workspace.");
await page.getByTestId("create-channel-private-toggle").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");
const agentCard = await page
.getByTestId("welcome-intro-action-create-agent")
.boundingBox();
const channelCard = await page
.getByTestId("welcome-intro-action-create-channel")
.boundingBox();
expect(agentCard).not.toBeNull();
expect(channelCard).not.toBeNull();
if (agentCard && channelCard) {
const sameRow = Math.abs(agentCard.y - channelCard.y) < 1;
expect(sameRow ? agentCard.x : agentCard.y).toBeLessThan(
sameRow ? channelCard.x : channelCard.y,
);
}
await page.getByTestId("welcome-intro-action-create-agent").click();
const dialog = page.getByRole("dialog");
await expect(
dialog.getByRole("heading", { name: "Create an agent" }),
).toBeVisible();
await waitForAnimations(page);
await dialog.screenshot({ path: `${SHOTS}/01-welcome-create-choice.png` });
await page.getByTestId("welcome-create-agent-in-chat").click();
await expect(dialog).not.toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
"Fizz, help me create a new agent.",
);
});
test("welcome manual creation opens the canonical agent form", async ({
page,
}) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
managedAgents: [
{
pubkey: FIZZ_PUBKEY,
name: "Fizz",
personaId: "builtin:fizz",
status: "running",
channelNames: ["Welcome"],
},
],
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill("Welcome");
await page.getByTestId("create-channel-private-toggle").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");
await page.getByTestId("welcome-intro-action-create-agent").click();
await page.getByTestId("welcome-create-agent-manually").click();
await expect(page).toHaveURL(/#\/channels\//);
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");
await expect(
page.getByRole("dialog").getByRole("heading", { name: "Create agent" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Advanced", exact: true }),
).toHaveAttribute("aria-expanded", "false");
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");
});
test("channel-targeted creation preserves the agent when attachment fails", async ({
page,
}) => {
await installMockBridge(page, {
addChannelMembersErrors: ["Relay unavailable.", null],
});
await openChannel(page, "random");
await page.getByTestId("channel-intro-action-create-agent").click();
await page.getByTestId("add-channel-create-agent").click();
await expect(page).toHaveURL(/#\/channels\//);
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.locator("#persona-display-name").fill("Scout");
await page
.locator("#persona-system-prompt")
.fill("Research a topic and return a concise brief.");
const provider = page.locator("#persona-llm-provider");
await provider.press("Enter");
await page
.getByRole("menuitemradio", { exact: true, name: "Anthropic" })
.click();
await page.locator("#persona-model").click();
await page
.getByRole("button", { name: "Custom model...", exact: true })
.click();
await page.getByLabel("Custom model ID").fill("claude-opus-4-5");
await page.getByLabel("Anthropic API Key").fill("sk-test-api-key-for-e2e");
await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled();
await page.getByTestId("persona-dialog-submit").click();
const createdDialog = page.getByRole("dialog");
await expect(
createdDialog.getByRole("heading", { name: "Agent created" }),
).toBeVisible({ timeout: 10_000 });
await expect(createdDialog).toContainText(
"Scout was created, but couldn’t be added to #random.",
);
await expect(createdDialog).toContainText("Relay unavailable.");
await waitForAnimations(page);
await createdDialog.screenshot({
path: `${SHOTS}/05-agent-channel-attachment-failed.png`,
});
const commandsBeforeRetry = await readCommandLog(page);
const createCount = commandCount(
commandsBeforeRetry,
"create_managed_agent",
);
const addCount = commandCount(commandsBeforeRetry, "add_channel_members");
await createdDialog.getByRole("button", { name: "Try again" }).click();
await expect(createdDialog).toContainText("Scout is ready and running.");
await expect(
createdDialog.getByRole("button", { name: "Try again" }),
).toHaveCount(0);
const commandsAfterRetry = await readCommandLog(page);
expect(commandCount(commandsAfterRetry, "create_managed_agent")).toEqual(
createCount,
);
expect(commandCount(commandsAfterRetry, "add_channel_members")).toEqual(
addCount + 1,
);
await createdDialog.getByRole("button", { name: "Done" }).click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
});
test("only Fizz is already in the channel", async ({ page }) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
managedAgents: [
{
pubkey: FIZZ_PUBKEY,
name: "Fizz",
personaId: "builtin:fizz",
status: "running",
channelNames: ["random"],
},
],
});
const dialog = await openAgentPicker(page);
await expect(dialog).toContainText(
"All of your agents are already in this channel.",
);
await dialog.screenshot({ path: `${SHOTS}/02-only-fizz-in-channel.png` });
});
test("some personal agents are available", async ({ page }) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
personas: [scoutPersona, editorPersona],
managedAgents: [
{
pubkey: FIZZ_PUBKEY,
name: "Fizz",
personaId: "builtin:fizz",
status: "running",
channelNames: ["random"],
},
{
pubkey: SCOUT_PUBKEY,
name: "Scout",
personaId: scoutPersona.id,
status: "stopped",
},
{
pubkey: EDITOR_PUBKEY,
name: "Editor",
personaId: editorPersona.id,
status: "stopped",
},
],
});
const dialog = await openAgentPicker(page);
await expect(dialog).toContainText("Your agents");
await expect(dialog).toContainText("Scout");
await expect(dialog).toContainText("Editor");
await dialog.screenshot({ path: `${SHOTS}/03-agents-available.png` });
});
test("all personal agents are already in the channel", async ({ page }) => {
await installMockBridge(page, {
activePersonaIds: ["builtin:fizz"],
personas: [scoutPersona, editorPersona],
managedAgents: [
{
pubkey: FIZZ_PUBKEY,
name: "Fizz",
personaId: "builtin:fizz",
status: "running",
channelNames: ["random"],
},
{
pubkey: SCOUT_PUBKEY,
name: "Scout",
personaId: scoutPersona.id,
status: "running",
channelNames: ["random"],
},
{
pubkey: EDITOR_PUBKEY,
name: "Editor",
personaId: editorPersona.id,
status: "running",
channelNames: ["random"],
},
],
});
const dialog = await openAgentPicker(page);
await expect(dialog).toContainText(
"All of your agents are already in this channel.",
);
await dialog.screenshot({ path: `${SHOTS}/04-all-agents-in-channel.png` });
});
});
+2
View File
@@ -154,6 +154,8 @@ type MockBridgeOptions = {
agentListDelayMs?: number;
createManagedAgentDelayMs?: number;
addChannelMembersDelayMs?: number;
/** Sequenced add-member failures. A string fails that call; null succeeds. */
addChannelMembersErrors?: (string | null)[];
channelMembersReadDelayMs?: number;
channelsReadError?: string;
/** Reject successive mock `create_channel` calls, then resume. */