From 1b7e5ac1be641f5ecc2b2a0ba37a1dc400e073c9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 17 Aug 2026 11:23:28 -0400 Subject: [PATCH 01/16] feat(model-capabilities): drive model capabilities and labels from one manifest (#5597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Centralizes model capability knowledge — thinking mode, supported effort levels, wire routes, and human-readable labels — into a single manifest, `scripts/model-capabilities.json`. Rust and TypeScript each get a small interpreter that reads the same manifest, replacing hand-maintained tables scattered across both languages that had already drifted apart. A capability change is now a data edit, not parallel edits to two code paths. Supersedes the codegen approach explored in #3603. A cross-language contract keeps the two interpreters honest: `scripts/normative-corpus.json` is a golden snapshot generated from the Rust resolver (103 vectors covering all six capability axes) and replayed natively in TS. CI fails if either language disagrees with the corpus or the corpus drifts from the resolver. Regenerate with `just regen-model-corpus`. ## Behavior changes - **Effort dropdown for `openai-compat` providers** no longer offers `max`. The request path always clamped `max` to `xhigh` on the wire, so the UI stops offering a value that was silently rewritten. UI-only, wire-identical. - **Databricks v2 routing (wire-visible):** uncurated endpoint names carrying a bare Claude code-name segment (e.g. `goose-opus-5`) now route to the MLflow chat wire instead of Anthropic Messages — they lose Anthropic prompt caching but still succeed on a valid OpenAI-compatible wire. Curated `databricks-claude-*` records and any name starting with `claude` are unchanged. A handful of other uncurated/adversarial name shapes similarly fall back to MLflow chat instead of pattern-matched routes; every curated model resolves identically to before, all axes. - **Curated model labels on the real discovery path.** The Databricks API returns no display name, so discovery emits the raw endpoint id as the model `name` (`{id, name: id}`) on every path. `ModelEntry.name` is now curated at all four construction seams in `buzz-agent` — v2 discovery, v1 parse, the auth-empty default catalog, and the configured-model fallback — via a read-only `databricks_registry_label` lookup over the manifest's `databricks_v2` exact records; `id` stays the raw wire/config value. A known id renders its curated label (`databricks-gpt-5-5` → `GPT-5.5`), an unknown id passes through unchanged, and the default-catalog row reads `GPT-5.5 (default catalog)`. As a defense against older `buzz-agent` binaries and any harness that echoes ids, `resolveModelLabel` treats a discovered name equal to the trimmed id as absent and falls through to the registry tier; a genuinely distinct name (including the suffixed default-catalog label) still wins. ## Cleanup Deletes the duplicated capability tables and their tests: the `config.rs` gpt5 matchers, effort tables, and clamp logic; the legacy segment-based Databricks v2 route classifier in `llm.rs`; and the TS hand tables plus `effortTable.fixture.json`. All are replaced by manifest lookups through the shared resolver — no line of capability data exists in two places. --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Co-authored-by: Duncan --- .github/workflows/ci.yml | 4 + Justfile | 17 + crates/buzz-agent/src/catalog.rs | 97 +- crates/buzz-agent/src/config.rs | 1533 +++--------- crates/buzz-agent/src/lib.rs | 28 +- crates/buzz-agent/src/llm.rs | 299 ++- crates/buzz-agent/src/model_capabilities.rs | 899 +++++++ desktop/src/features-manifest.d.ts | 5 + .../agents/lib/agentCardModelLabel.test.mjs | 137 ++ .../agents/lib/agentCardModelLabel.ts | 28 +- .../agents/lib/formatAgentModelLabel.ts | 72 +- .../features/agents/ui/AgentConfigFields.tsx | 5 +- .../features/agents/ui/ManagedAgentRow.tsx | 5 +- .../src/features/agents/ui/ModelPicker.tsx | 17 +- .../features/agents/ui/TeamIdentityCard.tsx | 2 +- .../agents/ui/UnifiedAgentsSection.tsx | 2 + .../agents/ui/buzzAgentConfig.test.mjs | 29 +- .../src/features/agents/ui/buzzAgentConfig.ts | 283 +-- .../agents/ui/effortTable.fixture.json | 254 -- .../agents/ui/effortTable.fixture.test.mjs | 52 - .../features/agents/ui/modelCapabilities.ts | 372 +++ .../ui/modelCapabilitiesCorpus.test.mjs | 122 + .../ui/usePersonaModelDiscovery.test.mjs | 127 + .../agents/ui/usePersonaModelDiscovery.ts | 5 +- .../profile/ui/UserProfilePopover.tsx | 9 +- desktop/test-loader-hooks.mjs | 4 + desktop/tsconfig.json | 3 +- desktop/vite.config.ts | 4 + scripts/model-capabilities.json | 1140 +++++++++ scripts/normative-corpus.json | 2074 +++++++++++++++++ scripts/run-tests.sh | 8 + 31 files changed, 5686 insertions(+), 1950 deletions(-) create mode 100644 crates/buzz-agent/src/model_capabilities.rs delete mode 100644 desktop/src/features/agents/ui/effortTable.fixture.json delete mode 100644 desktop/src/features/agents/ui/effortTable.fixture.test.mjs create mode 100644 desktop/src/features/agents/ui/modelCapabilities.ts create mode 100644 desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs create mode 100644 scripts/model-capabilities.json create mode 100644 scripts/normative-corpus.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f894c0e12fb..13d1424ea7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,10 +46,14 @@ jobs: - 'deny.toml' - '.github/workflows/ci.yml' - 'scripts/run-tests.sh' + - 'scripts/model-capabilities.json' + - 'scripts/normative-corpus.json' - 'justfile' desktop: - 'scripts/check-file-sizes-core.mjs' - 'scripts/check-file-sizes-core.test.mjs' + - 'scripts/model-capabilities.json' + - 'scripts/normative-corpus.json' - 'desktop/**' - '!desktop/src-tauri/**' - 'pnpm-lock.yaml' diff --git a/Justfile b/Justfile index 9e471784275..5b2ed88c952 100644 --- a/Justfile +++ b/Justfile @@ -323,6 +323,14 @@ test-unit: # because nothing in CI runs `cargo test --workspace` — workspace # membership alone buys clippy/check, not a single executed test. cargo nextest run -p buzz-backend-kubernetes + # buzz-agent model-capabilities corpus: the Rust half of the + # cross-language drift guard. `model_capabilities.rs` embeds + # scripts/model-capabilities.json + scripts/normative-corpus.json via + # include_str! and replays all 103 vectors as pure in-process tests (no + # infra). Enumerated explicitly because nothing in CI runs + # `cargo test --workspace`; without this step a manifest edit that + # diverges Rust from the corpus ships green. + cargo nextest run -p buzz-agent --lib else ./scripts/run-tests.sh unit fi @@ -331,6 +339,15 @@ test-unit: test-integration: ./scripts/run-tests.sh integration +# Regenerate the model-capability normative corpus from the production Rust +# resolver. The corpus is a golden snapshot, never hand-edited: this runs the +# `#[ignore]`d writer test in buzz-agent, which serializes `resolve()` over the +# inputs-only question table to scripts/normative-corpus.json. Run this after +# any model-capabilities.json edit, then commit the regenerated file. The +# `corpus_matches_generated_snapshot` gate fails CI if the committed file drifts. +regen-model-corpus: + cargo test -p buzz-agent --lib model_capabilities::tests::regen_corpus_file -- --ignored --exact + # Buzz shared compute e2e: current desktop discovery/admission logic and # Playwright UI coverage. mesh-e2e: diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index 0aaa2da7ea5..69714b145c5 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -23,28 +23,42 @@ use crate::{ types::AgentError, }; -/// A discovered model entry: `id` is the picker value, `name` is the display -/// label (same as `id` for Databricks — the API has no separate display name). +/// A discovered model entry: `id` is the picker value (the raw endpoint id, and +/// the wire/config value), `name` is the display label. The Databricks API has +/// no display-name field, so discovery curates `name` from the capability +/// manifest ([`model_capabilities::databricks_registry_label`]) — a known id +/// yields its curated label (e.g. `GPT-5.5`), an unknown id falls back to the +/// raw id. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelEntry { pub id: String, pub name: String, } -/// Known Databricks AI Gateway v2 models — used only when an authenticated -/// `api/ai-gateway/v2/endpoints` call succeeds with an empty list. -/// Mirrors goose's `DATABRICKS_V2_KNOWN_MODELS`. -pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = - &["databricks-gpt-5-5", "databricks-claude-opus-4-7"]; - const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)"; +/// Curated display label for a discovered Databricks endpoint id: the manifest's +/// exact-record label when one exists, otherwise the raw id. The API returns no +/// display name, so this is the single seam that turns a raw endpoint id into a +/// human label for the picker. +fn curated_model_name(id: &str) -> String { + crate::model_capabilities::databricks_registry_label(id) + .unwrap_or(id) + .to_string() +} + +/// Fallback catalog used only when an authenticated `api/ai-gateway/v2/endpoints` +/// call succeeds with an empty list. The known-model ids come from the manifest +/// ([`model_capabilities::databricks_v2_known_models`]), the single runtime source. fn authenticated_empty_v2_catalog() -> Vec { - DATABRICKS_V2_KNOWN_MODELS + crate::model_capabilities::databricks_v2_known_models() .iter() .map(|id| ModelEntry { - id: id.to_string(), - name: format!("{id}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}"), + id: id.clone(), + name: format!( + "{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}", + curated_model_name(id) + ), }) .collect() } @@ -205,8 +219,8 @@ pub(crate) fn parse_v1_endpoints(json: &serde_json::Value) -> Result = models.iter().map(|model| model.id.as_str()).collect(); - assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS); + let known: Vec<&str> = crate::model_capabilities::databricks_v2_known_models() + .iter() + .map(String::as_str) + .collect(); + assert_eq!(ids, known); + // `name` is the curated label + provenance suffix, not the raw id. assert!(models.iter().all(|model| { - model.name == format!("{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}", model.id) + let label = crate::model_capabilities::databricks_registry_label(&model.id) + .unwrap_or(model.id.as_str()); + model.name == format!("{label}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}") })); } + #[test] + fn v2_parse_curates_known_name_and_passes_unknown_through() { + // buzz-agent's real discovery contract: the endpoint id IS the name the + // API returns. A known id gets its manifest label; an unknown id stays raw. + let json = serde_json::json!({ + "endpoints": [ + {"name": "databricks-gpt-5-5"}, + {"name": "custom-unlisted-endpoint"}, + ] + }); + let (models, _) = parse_v2_endpoints_page(&json).unwrap(); + let by_id: std::collections::HashMap<&str, &str> = models + .iter() + .map(|m| (m.entry.id.as_str(), m.entry.name.as_str())) + .collect(); + assert_eq!(by_id["databricks-gpt-5-5"], "GPT-5.5"); + assert_eq!( + by_id["custom-unlisted-endpoint"], + "custom-unlisted-endpoint" + ); + } + + #[test] + fn v1_parse_curates_known_name_and_passes_unknown_through() { + let json = serde_json::json!({ + "endpoints": [ + {"name": "databricks-gpt-5-5", "task": "llm/v1/chat"}, + {"name": "custom-unlisted-endpoint", "task": "llm/v1/chat"}, + ] + }); + let models = parse_v1_endpoints(&json).unwrap(); + let by_id: std::collections::HashMap<&str, &str> = models + .iter() + .map(|m| (m.id.as_str(), m.name.as_str())) + .collect(); + assert_eq!(by_id["databricks-gpt-5-5"], "GPT-5.5"); + assert_eq!( + by_id["custom-unlisted-endpoint"], + "custom-unlisted-endpoint" + ); + } + #[test] fn is_chat_capable_endpoint_keeps_unrecognised_names() { // Prefer including over silently dropping — an unknown family is kept. diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index b29bf3d2fb8..67d7c593b56 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -15,7 +15,10 @@ pub const PROTOCOL_VERSION: u32 = 2; /// - **OpenAI Responses / Chat Completions**: effort support is model-dependent and normalized at /// request time; `max` is valid for documented max-supporting families such as GPT-5.6. /// - **Databricks**: routed by model family (Claude → Anthropic mapping, GPT-5 → Responses, MLflow → Chat). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize, +)] +#[serde(rename_all = "lowercase")] pub enum ThinkingEffort { None, Minimal, @@ -70,399 +73,6 @@ impl ThinkingEffort { } } -/// Strip any endpoint-naming prefix from a model name so the family classifiers -/// (`is_manual_budget_model`, `is_adaptive_thinking_model`, etc.) can match on the canonical -/// `claude-*` form regardless of how the model is stored in the Databricks catalog. -/// -/// Rather than maintaining an allowlist of known prefixes, this function finds the first -/// occurrence of a known model-family token (`claude-`, `gpt-`) and drops everything before -/// it. This handles any endpoint naming convention without needing to enumerate prefixes. -/// -/// Examples: -/// - `databricks-claude-fable-5` → `claude-fable-5` -/// - `goose-claude-fable-5` → `claude-fable-5` -/// - `team-x-claude-opus-4-7` → `claude-opus-4-7` -/// - `goose-gpt-5.5` → `gpt-5.5` -/// - `llama-3` → `llama-3` (no family token, returned unchanged) -/// -/// If no family token is present the name is returned unchanged. -fn strip_catalog_prefix(model: &str) -> &str { - const FAMILY_TOKENS: &[&str] = &["claude-", "gpt-"]; - let lower = model.to_ascii_lowercase(); - let first_idx = FAMILY_TOKENS.iter().filter_map(|tok| lower.find(tok)).min(); - match first_idx { - Some(idx) => &model[idx..], - None => model, - } -} - -/// Build the Anthropic thinking/effort request fields for the given model and effort level. -/// -/// API shape selection (per Anthropic thinking docs and per-model support table, -/// https://platform.claude.com/docs/en/build-with-claude/thinking and -/// https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#supported-models): -/// -/// **Adaptive families — `thinking:{type:"adaptive"}` activates effort control**: -/// -/// - Opus 4.6, Opus 4.7, Opus 4.8, Sonnet 4.6: status **Off** — thinking is OFF by default; -/// `thinking:{type:"adaptive"}` is required to enable thinking; without it no thinking occurs. -/// - Opus 5, Sonnet 5: status **On** — thinking is on by default (can be disabled); -/// we still send `thinking:{type:"adaptive"}` so `output_config.effort` is honoured. -/// - Fable 5, Mythos 5, Mythos Preview: status **Always on** — thinking cannot be disabled; -/// we still send `thinking:{type:"adaptive"}` so `output_config.effort` is honoured. -/// -/// In all three sub-buckets `output_config: {effort}` controls depth, clamped per-model. -/// Also sends `thinking: {display:"summarized"}` so thinking text is always visible in the -/// observer feed (without this, Anthropic defaults to `display:"omitted"` on newest models). -/// -/// **Manual-budget families** — `thinking: {type:"enabled", budget_tokens}`. -/// `budget_tokens` is clamped to `min(level_budget, max_output_tokens - 1024)` to preserve -/// at least 1024 answer tokens. If the result is < 1024 (i.e., `max_output_tokens <= 2047`), -/// thinking is omitted entirely with a `warn!`. -/// Doc-verified: claude-3* (legacy), claude-opus-4-5 (effort page: "uses manual thinking"). -/// Also sends `display:"summarized"` to ensure thinking text is returned. -/// -/// **Everything else** — omit both fields. This includes unknown/future `claude-*` names -/// not yet in the support table. Safer to omit than to guess an unverified shape. -/// -/// The Databricks `databricks-` and other endpoint-naming prefixes are stripped before -/// matching so that `databricks-claude-opus-4-7`, `goose-claude-fable-5`, and -/// `team-x-claude-opus-4-7` all route to the correct bucket. See `strip_catalog_prefix`. -/// -/// Returns `(thinking_field, output_config_field)` where each is `None` if not applicable. -pub fn anthropic_thinking_config( - effective_model: &str, - effort: ThinkingEffort, - max_output_tokens: u32, -) -> (Option, Option) { - use serde_json::json; - // Normalise the model name for matching: strip any endpoint-naming prefix - // (e.g. "databricks-claude-opus-4-7" → "claude-opus-4-7", - // "goose-claude-fable-5" → "claude-fable-5", - // "team-x-claude-opus-4-7" → "claude-opus-4-7"). - let model = strip_catalog_prefix(effective_model); - - if is_manual_budget_model(model) { - // Manual-budget shape: budget_tokens must be strictly < max_tokens AND must leave - // at least MIN_ANSWER_TOKENS (1024) for the visible answer. The Anthropic API - // requires budget_tokens < max_tokens AND budget_tokens >= 1024. - // - // Clamp: budget = min(level_budget, max_output_tokens - MIN_ANSWER_TOKENS). - // If result < MIN_ANSWER_TOKENS, thinking would starve the answer — omit thinking - // entirely and warn instead of emitting an invalid or answer-starving budget. - const MIN_ANSWER_TOKENS: u32 = 1024; - let level_budget = effort.anthropic_budget_tokens(); - let headroom = max_output_tokens.saturating_sub(MIN_ANSWER_TOKENS); - let budget = level_budget.min(headroom); - if budget < MIN_ANSWER_TOKENS { - tracing::warn!( - max_output_tokens, - level_budget, - headroom, - "BUZZ_AGENT_THINKING_EFFORT: max_output_tokens too small to fit thinking budget + answer headroom; omitting thinking fields" - ); - return (None, None); - } - ( - Some(json!({ "type": "enabled", "budget_tokens": budget, "display": "summarized" })), - None, - ) - } else if is_adaptive_thinking_model(model) { - // Adaptive families: we always send type:"adaptive" to activate output_config.effort. - // Sub-bucket A (Off: Opus 4.6/4.7/4.8, Sonnet 4.6): this field is required to enable - // thinking at all. Sub-bucket B (On: Opus 5/Sonnet 5) and sub-bucket C (Always on: - // Fable 5/Mythos 5/Mythos Preview): thinking is already on; we send the field so - // output_config.effort is honoured, not to enable thinking. - // Apply per-model effort clamping: if the requested level exceeds the model's - // doc-verified maximum, clamp down to the highest supported level with a warning. - let clamped = clamp_adaptive_effort(model, effort); - ( - Some(json!({ "type": "adaptive", "display": "summarized" })), - Some(json!({ "effort": clamped.anthropic_effort_str() })), - ) - } else { - // Unrecognised or unverified model name — omit both fields rather than guess. - // This includes unknown future claude-* names not yet in the support table. - (None, None) - } -} - -/// Returns true for adaptive Anthropic models that support the `xhigh` effort level. -/// -/// Used by both `clamp_adaptive_effort` (request-time) and `anthropic_efforts_for_model` -/// (UI capability table) to keep xhigh-support classification in a single place. -/// -/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). -fn anthropic_model_supports_xhigh(model: &str) -> bool { - model.starts_with("claude-opus-4-7") - || model.starts_with("claude-opus-4-8") - || model.starts_with("claude-opus-5") - || model.starts_with("claude-sonnet-5") - || model.starts_with("claude-fable-5") - || model.starts_with("claude-mythos-5") -} - -/// Clamp the requested effort level to the highest doc-verified level for the given adaptive model. -/// -/// Doc-verified availability (Anthropic effort page, July 2025): -/// - `max`: Opus 4.8, 4.7, 4.6; Sonnet 5.x, 4.6; Fable 5; Mythos 5; Mythos Preview -/// - `xhigh`: Opus 4.8, 4.7; Sonnet 5.x; Fable 5; Mythos 5 -/// (NOT Opus 4.6, Sonnet 4.6, or Mythos Preview) -/// - `low|medium|high`: all adaptive families -/// -/// If the requested level is not available for the model, clamps down to the highest -/// supported level below the requested one, and logs a warning. This is dynamic (not -/// startup-time) because `session/set_model` can change the model after startup. -/// -/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). -pub fn clamp_adaptive_effort(model: &str, effort: ThinkingEffort) -> ThinkingEffort { - // Models that support all levels including xhigh (and max). - let supports_xhigh = anthropic_model_supports_xhigh(model); - - let clamped = if supports_xhigh { - effort // all levels pass through - } else if effort == ThinkingEffort::XHigh { - // xhigh not available for this model; clamp to high (the highest supported below xhigh). - ThinkingEffort::High - } else { - effort // low/medium/high/max all pass through for the other adaptive families - }; - - if clamped != effort { - tracing::warn!( - model, - requested = effort.openai_effort_str(), - clamped = clamped.openai_effort_str(), - "BUZZ_AGENT_THINKING_EFFORT is not available for this model; clamping to highest supported level" - ); - } - clamped -} - -/// Returns true if `lower_model` contains `token` as a bounded family segment — i.e., the -/// token is immediately followed by end-of-string or a `-` separator (not a digit or letter). -/// -/// This prevents: -/// - `gpt-5.1` from matching `gpt-5.10` (digit follows the `1`) -/// - `gpt-5-1` from matching `gpt-5-1106` (digit follows the `1`) -/// - `gpt-5-4` from matching `gpt-5-4o` (letter follows the `4`) -/// -/// Gateway prefixes (`databricks-`) and date/build suffixes (`-2025-04-01`) are allowed -/// because they start with `-` which is the only permitted boundary character. -fn gpt5_token_matches(lower_model: &str, token: &str) -> bool { - let mut start = 0; - while let Some(pos) = lower_model[start..].find(token) { - let abs = start + pos; - let after = abs + token.len(); - // The character immediately after the token must be end-of-string or '-'. - // Any alphanumeric character (digit OR letter) means this is a longer token, not - // the family we're looking for. - let safe_suffix = lower_model[after..].chars().next().is_none_or(|c| c == '-'); - if safe_suffix { - return true; - } - start = abs + 1; - } - false -} - -/// Like `gpt5_token_matches` but additionally rejects short version-like numeric suffixes — -/// used for the base `gpt-5` / `gpt5` token to avoid false-matching unrecognized versions. -/// -/// After a `-` separator: -/// - `-…` e.g. `-pro` → **accepted** (capability suffix, no digits) -/// - `digit_run == 1-3` AND the char right after the digits is a **letter** e.g. `-4o` → -/// **accepted** (real variant shape: digit + letter) -/// - `digit_run == 1-3` AND the char after the digits is end-of-string, `-`, `.`, or other -/// separator e.g. `-10`, `-10-preview` → **rejected** (version-like suffix) -/// - `digit_run >= 4` regardless of what follows e.g. `-1106`, `-1106-preview`, `-0514` → -/// **accepted** (date/build segment) -fn gpt5_base_matches(lower_model: &str, token: &str) -> bool { - let mut start = 0; - while let Some(pos) = lower_model[start..].find(token) { - let abs = start + pos; - let after = abs + token.len(); - let rest = &lower_model[after..]; - let safe_suffix = if rest.is_empty() { - // End of string — clean boundary. - true - } else if let Some(tail) = rest.strip_prefix('-') { - // Count leading digits in the suffix component. - let digit_run: usize = tail.chars().take_while(|c| c.is_ascii_digit()).count(); - if digit_run == 0 { - // No leading digit (e.g. '-pro'): capability suffix → accepted. - true - } else if digit_run >= 4 { - // 4+ digit run (e.g. '-1106', '-1106-preview', '-0514'): date/build → accepted. - true - } else { - // 1-3 digit run: accepted only if the char right after the digits is a letter - // (real variant shape like '-4o'). Separator/EOS after short digits is - // version-like (e.g. '-10', '-10-preview') → rejected. - tail[digit_run..] - .chars() - .next() - .is_some_and(|c| c.is_ascii_alphabetic()) - } - } else { - // Dot, letter, or other non-hyphen character directly after token → not base. - false - }; - if safe_suffix { - return true; - } - start = abs + 1; - } - false -} - -/// Returns the set of `reasoning.effort` values supported by a given OpenAI model family. -/// -/// Doc-verified availability (OpenAI model pages, July 2025): -/// -/// | Model | Supported effort values | -/// |-------------|-------------------------------------------| -/// | gpt-5-pro | `high` only | -/// | gpt-5.6 | `none, low, medium, high, xhigh, max` | -/// | gpt-5.5 | `none, low, medium, high, xhigh` | -/// | gpt-5.4 | `none, low, medium, high, xhigh` | -/// | gpt-5.1 | `none, low, medium, high` | -/// | gpt-5 (base)| `minimal, low, medium, high` | -/// | unknown | not doc-verified — `max` clamps to `xhigh` | -/// -/// Note the `none` vs `minimal` split: `gpt-5` (base) supports `minimal` but not `none`; -/// `gpt-5.1`/`gpt-5.4`/`gpt-5.5`/`gpt-5.6` support `none` but not `minimal`. These are matched via -/// nearest-supported fallback in `normalize_effort_for_openai_route`. -/// -/// Match order: `-pro` variant checked before versioned strings to prevent `gpt-5-pro` from -/// falling into the `gpt-5` base bucket (substring "gpt-5" is shared). -/// -/// `model` is a raw model name (may include Databricks gateway prefixes or date suffixes). -/// Unknown models return `None` — callers pass through values except `max`, which clamps to -/// `xhigh` until support is confirmed. -/// Versioned tokens use `gpt5_token_matches` (end-of-string or `-` boundary, blocking digit -/// and letter continuations). The base token uses `gpt5_base_matches`, which additionally -/// rejects short `-<1-3 digit>` suffixes that look like two-digit version numbers. -fn openai_efforts_for_model(model: &str) -> Option<&'static [ThinkingEffort]> { - // Effort ordered from lowest to highest for each family. - const GPT5_PRO: &[ThinkingEffort] = &[ThinkingEffort::High]; - const GPT5_6: &[ThinkingEffort] = &[ - ThinkingEffort::None, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ]; - const GPT5_5_AND_5_4: &[ThinkingEffort] = &[ - ThinkingEffort::None, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ]; - const GPT5_1: &[ThinkingEffort] = &[ - ThinkingEffort::None, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ]; - const GPT5_BASE: &[ThinkingEffort] = &[ - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ]; - - let lower = model.to_ascii_lowercase(); - // Check gpt-5-pro before gpt-5.5 / gpt-5.4 etc. to avoid the `-pro` name - // matching the base "gpt-5" prefix first. - if gpt5_token_matches(&lower, "gpt-5-pro") || gpt5_token_matches(&lower, "gpt5-pro") { - Some(GPT5_PRO) - } else if gpt5_token_matches(&lower, "gpt-5.6") - || gpt5_token_matches(&lower, "gpt5.6") - || gpt5_token_matches(&lower, "gpt-5-6") - || gpt5_token_matches(&lower, "gpt5-6") - { - Some(GPT5_6) - } else if gpt5_token_matches(&lower, "gpt-5.5") - || gpt5_token_matches(&lower, "gpt5.5") - || gpt5_token_matches(&lower, "gpt-5-5") - || gpt5_token_matches(&lower, "gpt5-5") - || gpt5_token_matches(&lower, "gpt-5.4") - || gpt5_token_matches(&lower, "gpt5.4") - || gpt5_token_matches(&lower, "gpt-5-4") - || gpt5_token_matches(&lower, "gpt5-4") - { - // gpt-5.5 and gpt-5.4 share the same effort availability table. - Some(GPT5_5_AND_5_4) - } else if gpt5_token_matches(&lower, "gpt-5.1") - || gpt5_token_matches(&lower, "gpt5.1") - || gpt5_token_matches(&lower, "gpt-5-1") - || gpt5_token_matches(&lower, "gpt5-1") - { - Some(GPT5_1) - } else if gpt5_base_matches(&lower, "gpt-5") || gpt5_base_matches(&lower, "gpt5") { - // Base gpt-5 (no version suffix matching any of the above). - Some(GPT5_BASE) - } else { - // Unknown model — not doc-verified; server validates. - None - } -} - -/// Returns the effort capability set for a given Anthropic model. -/// -/// This is the single production source of truth for Anthropic family routing. -/// Both `anthropic_thinking_config` (request-time) and the effort-table UI -/// (`valid_effort_values_for_provider_model`, via its Anthropic branch) must -/// derive their behaviour from this helper so the two stay in sync. -/// -/// Returns `(valid_values, default)` where: -/// - `valid_values` is the static slice of `ThinkingEffort` values accepted -/// by this model family's effort dropdown. -/// - `default` is `None` for manual-budget models (no semantic default — -/// user must choose) or `Some(High)` for adaptive families. -/// -/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). -pub fn anthropic_efforts_for_model( - model: &str, -) -> (&'static [ThinkingEffort], Option) { - const MANUAL: &[ThinkingEffort] = &[ - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ]; - const ADAPTIVE_XHIGH: &[ThinkingEffort] = &[ - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ]; - const ADAPTIVE_NO_XHIGH: &[ThinkingEffort] = &[ - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::Max, - ]; - - if is_manual_budget_model(model) { - return (MANUAL, None); - } - if is_adaptive_thinking_model(model) { - // Reuse `anthropic_model_supports_xhigh` (the single source of truth - // shared with `clamp_adaptive_effort`) — no side-effects, no duplication. - if anthropic_model_supports_xhigh(model) { - return (ADAPTIVE_XHIGH, Some(ThinkingEffort::High)); - } else { - return (ADAPTIVE_NO_XHIGH, Some(ThinkingEffort::High)); - } - } - // Unknown Anthropic model — assume full adaptive (xhigh-capable) as a safe default. - (ADAPTIVE_XHIGH, Some(ThinkingEffort::High)) -} - /// Resolve the nearest supported effort level for a given OpenAI model. /// /// When the requested effort is not in the model's supported set, falls back to the @@ -532,37 +142,6 @@ fn resolve_openai_effort( resolved } -/// Normalize the effort value for an OpenAI-shaped request body (Chat Completions or Responses). -/// -/// Per-model effort availability is applied for doc-verified OpenAI model families. A requested -/// level not in the model's supported set is substituted with the nearest supported level (see -/// `resolve_openai_effort` for preference order). For unknown/unverified models, `max` is clamped -/// to `xhigh` because its support cannot be confirmed; all other values pass through unchanged. -/// -/// Applies to pure-OpenAI request paths AND DBv2 OpenAI-shaped routes. -/// -/// Doc-verified model table (July 2025): -/// - `gpt-5-pro`: `high` only -/// - `gpt-5.6`: `none, low, medium, high, xhigh, max` -/// - `gpt-5.5`, `gpt-5.4`: `none, low, medium, high, xhigh` -/// - `gpt-5.1`: `none, low, medium, high` -/// - `gpt-5` (base): `minimal, low, medium, high` -/// - unknown: `max` clamps to `xhigh`; other values pass through -pub fn normalize_effort_for_openai_route(effort: ThinkingEffort, model: &str) -> ThinkingEffort { - match openai_efforts_for_model(model) { - Some(supported) => resolve_openai_effort(model, effort, supported), - None if effort == ThinkingEffort::Max => { - tracing::warn!( - requested = "max", - resolved = "xhigh", - "BUZZ_AGENT_THINKING_EFFORT=max not confirmed for unknown OpenAI model; clamping to xhigh" - ); - ThinkingEffort::XHigh - } - None => effort, - } -} - /// Normalize the effort value for an Anthropic-shaped request body (Messages API). /// /// Anthropic-shaped bodies (`anthropic_body`) do not have a `none` or `minimal` concept — @@ -588,57 +167,144 @@ pub fn normalize_effort_for_anthropic_route(effort: ThinkingEffort) -> Option bool { - model.starts_with("claude-3") || model == "claude-opus-4-5" +/// This is the single production authority for `Provider::OpenAi` and `Provider::Databricks` +/// effort normalization. +pub fn normalize_effort_for_provider( + provider: &str, + raw_model: &str, + effort: ThinkingEffort, +) -> ThinkingEffort { + let cap = crate::model_capabilities::resolve(provider, raw_model); + resolve_openai_effort(raw_model, effort, cap.supported_efforts) } -/// Returns true for Claude model families that use adaptive thinking (doc-verified against -/// https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#supported-models). -/// -/// **Sub-bucket A — status Off (thinking OFF until `thinking:{type:"adaptive"}` is sent)**: -/// Opus 4.6, Opus 4.7, Opus 4.8, Sonnet 4.6. -/// -/// **Sub-bucket B — status On (thinking on by default; can be disabled)**: -/// Opus 5, Sonnet 5. -/// We still send `thinking:{type:"adaptive"}` so `output_config.effort` is honoured. +/// Normalize the effort value for a DatabricksV2 OpenAI-shaped request (Responses / MLflow). /// -/// **Sub-bucket C — status Always on (thinking cannot be disabled)**: -/// Fable 5, Mythos 5, Mythos Preview. -/// We still send `thinking:{type:"adaptive"}` so `output_config.effort` is honoured. +/// Reads `normalization_policy` from the manifest record for this raw model id +/// (`provider = "databricks_v2"`) and applies it: +/// - `OpenaiStandard` → resolve against the record's `supported_efforts` (the axis +/// that carries the adopted exact-record corrections). +/// - `OpenaiClampMaxToXhigh` → clamp `max`→`xhigh` with a DBv2-specific warning; resolve +/// any other unsupported value against `supported_efforts`. +/// - `None` → pass the effort through unchanged (Anthropic-routed models, +/// which are normalized by `normalize_effort_for_anthropic_route` and never reach here). /// -/// All three sub-buckets accept the same request shape. The distinction matters only when -/// thinking effort is NOT configured: sub-bucket B/C models still produce thinking even -/// without us sending the field; sub-bucket A models do not. +/// This is the production authority for DatabricksV2 OpenAI-shaped effort normalization; +/// `normalize_effort_for_provider` covers pure OpenAI and legacy Databricks. +pub fn normalize_effort_for_databricks_v2( + effort: ThinkingEffort, + raw_model: &str, +) -> ThinkingEffort { + use crate::model_capabilities::NormalizationPolicy; + let cap = crate::model_capabilities::resolve("databricks_v2", raw_model); + match cap.normalization_policy { + NormalizationPolicy::OpenaiStandard => { + resolve_openai_effort(raw_model, effort, cap.supported_efforts) + } + NormalizationPolicy::OpenaiClampMaxToXhigh => { + if effort == ThinkingEffort::Max { + tracing::warn!( + requested = "max", + resolved = "xhigh", + model = raw_model, + "BUZZ_AGENT_THINKING_EFFORT=max not confirmed for this DatabricksV2 model; clamping to xhigh" + ); + ThinkingEffort::XHigh + } else { + resolve_openai_effort(raw_model, effort, cap.supported_efforts) + } + } + NormalizationPolicy::None => effort, + } +} + +/// Build the Anthropic thinking/effort request fields for any manifest-owned provider/model. /// -/// Note: Opus 4.5 is NOT in this bucket — it uses manual budget (see `is_manual_budget_model`). -/// No prefix wildcards over version numbers; each entry is doc-verified explicitly. +/// Resolves `thinking_mode` and `supported_efforts` from the manifest record for the +/// effective provider/model and applies them: +/// - `ManualBudget` → `thinking:{type:"enabled", budget_tokens, display:"summarized"}`, +/// with `budget_tokens` clamped to leave at least 1024 answer tokens (both fields omitted +/// when `max_output_tokens` is too small to fit thinking budget + answer headroom). +/// - `Adaptive` → `thinking:{type:"adaptive", display:"summarized"}` + +/// `output_config:{effort}`, with effort clamped down to the highest supported level. +/// - `None` / `OmitFields` → omit both fields (non-thinking model, or unknown/unverified +/// Anthropic name — safer to omit than to guess an unsupported request shape). /// -/// `model` must already have catalog prefixes stripped (via `strip_catalog_prefix`). -fn is_adaptive_thinking_model(model: &str) -> bool { - // Exact version strings for Opus 4.x adaptive models (4.6, 4.7, 4.8). - // Opus 4.5 is excluded — manual budget only. - model.starts_with("claude-opus-4-6") - || model.starts_with("claude-opus-4-7") - || model.starts_with("claude-opus-4-8") - || model.starts_with("claude-opus-5") - // Sonnet 5.x (any patch/date suffix after "claude-sonnet-5"). - || model.starts_with("claude-sonnet-5") - // Sonnet 4.6 exactly (not Sonnet 4.5 or earlier — not in the adaptive table). - || model.starts_with("claude-sonnet-4-6") - // Fable 5 and Mythos 5 (Always on — thinking cannot be disabled, July 2025). - || model.starts_with("claude-fable-5") - || model.starts_with("claude-mythos-5") - // Mythos Preview (Always on — thinking cannot be disabled, July 2025). - // Note: xhigh is NOT available on Mythos Preview — clamp_adaptive_effort handles this. - || model.starts_with("claude-mythos-preview") +/// `display:"summarized"` keeps thinking text visible in the observer feed (Anthropic +/// defaults to `display:"omitted"` on the newest models). This is the single production +/// authority for all providers' Anthropic thinking body construction. +pub fn anthropic_thinking_config( + provider: &str, + effective_model: &str, + effort: ThinkingEffort, + max_output_tokens: u32, +) -> (Option, Option) { + use crate::model_capabilities::ThinkingMode; + use serde_json::json; + + let cap = crate::model_capabilities::resolve(provider, effective_model); + match cap.thinking_mode { + ThinkingMode::ManualBudget => { + // Manual-budget shape (claude-3*, claude-opus-4-5): budget_tokens clamped to + // fit within max_output_tokens while preserving at least MIN_ANSWER_TOKENS. + const MIN_ANSWER_TOKENS: u32 = 1024; + let level_budget = effort.anthropic_budget_tokens(); + let headroom = max_output_tokens.saturating_sub(MIN_ANSWER_TOKENS); + let budget = level_budget.min(headroom); + if budget < MIN_ANSWER_TOKENS { + tracing::warn!( + max_output_tokens, + level_budget, + headroom, + model = effective_model, + "BUZZ_AGENT_THINKING_EFFORT: max_output_tokens too small to fit thinking budget + answer headroom; omitting thinking fields" + ); + return (None, None); + } + ( + Some( + json!({ "type": "enabled", "budget_tokens": budget, "display": "summarized" }), + ), + None, + ) + } + ThinkingMode::Adaptive => { + // Adaptive shape: clamp effort downward to the highest supported level using the + // manifest's supported_efforts (sorted ascending by validate_manifest). + let clamped = cap + .supported_efforts + .iter() + .rev() + .find(|&&e| e <= effort) + .copied() + .unwrap_or(effort); // effort below the lowest supported; pass through (rare) + if clamped != effort { + tracing::warn!( + model = effective_model, + requested = effort.openai_effort_str(), + clamped = clamped.openai_effort_str(), + "BUZZ_AGENT_THINKING_EFFORT is not available for this model; clamping to highest supported level" + ); + } + ( + Some(json!({ "type": "adaptive", "display": "summarized" })), + Some(json!({ "effort": clamped.anthropic_effort_str() })), + ) + } + ThinkingMode::None | ThinkingMode::OmitFields => { + // Non-thinking model, or unknown/unverified Anthropic name: omit rather than guess. + (None, None) + } + } } /// Reasoning summary mode for the OpenAI Responses API route. @@ -1080,8 +746,9 @@ impl Config { // // OpenAI, Databricks, and DatabricksV2 defer effort validation to request-time routing: // availability is model-dependent, and `session/set_model` can change the effective model - // after startup. `normalize_effort_for_openai_route` / `normalize_effort_for_anthropic_route` - // apply route-aware normalization in `llm.rs` when building each request. + // after startup. `normalize_effort_for_provider` / `normalize_effort_for_databricks_v2` / + // `normalize_effort_for_anthropic_route` apply route-aware normalization in `llm.rs` when + // building each request. if let Some(effort) = self.thinking_effort { let is_pure_anthropic = matches!(self.provider, Provider::Anthropic); if is_pure_anthropic && matches!(effort, ThinkingEffort::None | ThinkingEffort::Minimal) @@ -1679,8 +1346,12 @@ mod tests { fn anthropic_thinking_config_claude3_emits_budget_tokens() { // Claude 3.x → `thinking.budget_tokens`; clamped to min(level_budget, max_output - 1024). // max_output_tokens = 4096: headroom = 4096 - 1024 = 3072; High budget (32768) → 3072. - let (thinking, output_config) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::High, 4096); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-3-7-sonnet-20250219", + ThinkingEffort::High, + 4096, + ); let t = thinking.expect("thinking field must be present for claude-3"); assert_eq!(t["type"], "enabled"); assert_eq!(t["budget_tokens"], 3072); // capped: min(32768, 4096-1024) @@ -1693,8 +1364,12 @@ mod tests { #[test] fn anthropic_thinking_config_claude3_omits_thinking_when_max_output_too_small() { // max_output_tokens = 2047: headroom = 2047 - 1024 = 1023 < 1024 → omit thinking. - let (thinking, output_config) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::High, 2047); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-3-7-sonnet-20250219", + ThinkingEffort::High, + 2047, + ); assert!( thinking.is_none(), "thinking must be omitted when max_output_tokens - 1024 < 1024 (budget would starve answer)" @@ -1705,8 +1380,12 @@ mod tests { #[test] fn anthropic_thinking_config_claude3_emits_thinking_at_boundary_2048() { // max_output_tokens = 2048: headroom = 2048 - 1024 = 1024 ≥ 1024 → emit budget = 1024. - let (thinking, _) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::High, 2048); + let (thinking, _) = anthropic_thinking_config( + "anthropic", + "claude-3-7-sonnet-20250219", + ThinkingEffort::High, + 2048, + ); let t = thinking.expect("thinking must be present when max_output_tokens = 2048"); assert_eq!(t["budget_tokens"], 1024); // min(32768, 2048-1024) = 1024 } @@ -1714,8 +1393,12 @@ mod tests { #[test] fn anthropic_thinking_config_claude3_budget_uncapped_when_fits() { // High budget fits comfortably under a large max_output_tokens. - let (thinking, _) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::High, 65_536); + let (thinking, _) = anthropic_thinking_config( + "anthropic", + "claude-3-7-sonnet-20250219", + ThinkingEffort::High, + 65_536, + ); let t = thinking.unwrap(); assert_eq!(t["budget_tokens"], 32_768); } @@ -1724,7 +1407,7 @@ mod tests { fn anthropic_thinking_config_opus_4_8_emits_adaptive_and_effort() { // Opus 4.8 — adaptive family. Requires thinking:{type:"adaptive"} to enable thinking. let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-8", ThinkingEffort::High, 32_768); + anthropic_thinking_config("anthropic", "claude-opus-4-8", ThinkingEffort::High, 32_768); let t = thinking.expect("thinking must be present for claude-opus-4-8"); assert_eq!(t["type"], "adaptive"); let oc = output_config.expect("output_config must be present for claude-opus-4-8"); @@ -1734,8 +1417,12 @@ mod tests { #[test] fn anthropic_thinking_config_opus_4_7_emits_adaptive_and_effort() { // Opus 4.7 — adaptive family. - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-7", ThinkingEffort::Medium, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-opus-4-7", + ThinkingEffort::Medium, + 32_768, + ); let t = thinking.expect("thinking must be present for claude-opus-4-7"); assert_eq!(t["type"], "adaptive"); let oc = output_config.expect("output_config must be present for claude-opus-4-7"); @@ -1745,8 +1432,12 @@ mod tests { #[test] fn anthropic_thinking_config_sonnet_5_emits_adaptive_and_effort() { // Sonnet 5 — adaptive family. - let (thinking, output_config) = - anthropic_thinking_config("claude-sonnet-5-20250901", ThinkingEffort::Low, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-sonnet-5-20250901", + ThinkingEffort::Low, + 32_768, + ); let t = thinking.expect("thinking must be present for claude-sonnet-5"); assert_eq!(t["type"], "adaptive"); let oc = output_config.expect("output_config must be present for claude-sonnet-5"); @@ -1756,8 +1447,12 @@ mod tests { #[test] fn anthropic_thinking_config_sonnet_4_6_emits_adaptive_and_effort() { // Sonnet 4.6 — adaptive family. Docs explicitly list "Combine effort with adaptive thinking." - let (thinking, output_config) = - anthropic_thinking_config("claude-sonnet-4-6", ThinkingEffort::High, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-sonnet-4-6", + ThinkingEffort::High, + 32_768, + ); let t = thinking.expect("thinking must be present for claude-sonnet-4-6"); assert_eq!(t["type"], "adaptive"); let oc = output_config.expect("output_config must be present for claude-sonnet-4-6"); @@ -1768,7 +1463,7 @@ mod tests { fn anthropic_thinking_config_opus_4_5_emits_manual_budget() { // Opus 4.5 — manual budget (NOT adaptive; effort page: "uses manual thinking"). let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-5", ThinkingEffort::High, 65_536); + anthropic_thinking_config("anthropic", "claude-opus-4-5", ThinkingEffort::High, 65_536); let t = thinking.expect("thinking must be present for claude-opus-4-5"); assert_eq!(t["type"], "enabled"); assert_eq!(t["budget_tokens"], 32_768); // High budget fits under 65536 @@ -1783,7 +1478,7 @@ mod tests { // Opus 4.5 manual budget is clamped to min(level_budget, max_output_tokens - 1024). // max_output_tokens = 4096: headroom = 4096 - 1024 = 3072; High budget (32768) → 3072. let (thinking, _) = - anthropic_thinking_config("claude-opus-4-5", ThinkingEffort::High, 4096); + anthropic_thinking_config("anthropic", "claude-opus-4-5", ThinkingEffort::High, 4096); let t = thinking.unwrap(); assert_eq!(t["budget_tokens"], 3072); // min(32768, 4096-1024) } @@ -1792,7 +1487,7 @@ mod tests { fn anthropic_thinking_config_opus_4_5_omits_thinking_when_max_output_1025() { // max_output_tokens = 1025: headroom = 1025 - 1024 = 1 < 1024 → omit thinking. let (thinking, _) = - anthropic_thinking_config("claude-opus-4-5", ThinkingEffort::High, 1025); + anthropic_thinking_config("anthropic", "claude-opus-4-5", ThinkingEffort::High, 1025); assert!( thinking.is_none(), "thinking must be omitted when max_output_tokens - 1024 < 1024" @@ -1803,8 +1498,12 @@ mod tests { fn anthropic_thinking_config_manual_budget_low_emits_1024_when_fits() { // Low budget (1024 tokens) exactly fits when max_output_tokens = 2048. // headroom = 2048 - 1024 = 1024; min(1024, 1024) = 1024 ≥ 1024 → emit. - let (thinking, _) = - anthropic_thinking_config("claude-3-7-sonnet-20250219", ThinkingEffort::Low, 2048); + let (thinking, _) = anthropic_thinking_config( + "anthropic", + "claude-3-7-sonnet-20250219", + ThinkingEffort::Low, + 2048, + ); let t = thinking.expect("Low budget (1024) must be emitted when max_output_tokens = 2048"); assert_eq!(t["budget_tokens"], 1024); } @@ -1822,7 +1521,7 @@ mod tests { "claude-opus-4-9", ] { let (thinking, output_config) = - anthropic_thinking_config(model, ThinkingEffort::High, 32_768); + anthropic_thinking_config("anthropic", model, ThinkingEffort::High, 32_768); assert!( thinking.is_none(), "thinking must be absent for unverified claude model: {model}" @@ -1838,7 +1537,7 @@ mod tests { fn anthropic_thinking_config_non_claude_omits_both_fields() { // Non-Anthropic model names (gpt-5, llama, etc.) → omit both fields. let (thinking, output_config) = - anthropic_thinking_config("gpt-4o-mini", ThinkingEffort::High, 32_768); + anthropic_thinking_config("anthropic", "gpt-4o-mini", ThinkingEffort::High, 32_768); assert!( thinking.is_none(), "thinking must be absent for non-claude model" @@ -1852,8 +1551,12 @@ mod tests { #[test] fn anthropic_thinking_config_databricks_prefix_stripped_for_claude3() { // Databricks gateway prefixes like "databricks-claude-3-..." must be stripped. - let (thinking, output_config) = - anthropic_thinking_config("databricks-claude-3-5-sonnet", ThinkingEffort::Low, 8_192); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "databricks-claude-3-5-sonnet", + ThinkingEffort::Low, + 8_192, + ); let t = thinking.expect("thinking must be present after stripping databricks- prefix"); assert_eq!(t["type"], "enabled"); assert!(output_config.is_none()); @@ -1862,8 +1565,12 @@ mod tests { #[test] fn anthropic_thinking_config_databricks_prefix_stripped_for_opus_4_7() { // Databricks gateway prefix stripping applies to adaptive Claude families too. - let (thinking, output_config) = - anthropic_thinking_config("databricks-claude-opus-4-7", ThinkingEffort::High, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "databricks-claude-opus-4-7", + ThinkingEffort::High, + 32_768, + ); let t = thinking .expect("thinking:{type:adaptive} must be present for databricks-claude-opus-4-7"); assert_eq!(t["type"], "adaptive"); @@ -1875,8 +1582,12 @@ mod tests { #[test] fn anthropic_thinking_config_databricks_prefix_stripped_for_opus_4_8() { // Databricks gateway prefix stripping applies to Opus 4.8 too. - let (thinking, output_config) = - anthropic_thinking_config("databricks-claude-opus-4-8", ThinkingEffort::Medium, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "databricks-claude-opus-4-8", + ThinkingEffort::Medium, + 32_768, + ); let t = thinking .expect("thinking:{type:adaptive} must be present for databricks-claude-opus-4-8"); assert_eq!(t["type"], "adaptive"); @@ -1889,8 +1600,12 @@ mod tests { fn anthropic_thinking_config_goose_prefix_stripped_for_fable_5() { // "goose-" catalog prefix must be stripped so goose-claude-fable-5 routes to // the adaptive + xhigh/max bucket, not the "unknown model → (None, None)" path. - let (thinking, output_config) = - anthropic_thinking_config("goose-claude-fable-5", ThinkingEffort::Max, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "goose-claude-fable-5", + ThinkingEffort::Max, + 32_768, + ); let t = thinking.expect("thinking:{type:adaptive} must be present for goose-claude-fable-5"); assert_eq!(t["type"], "adaptive"); @@ -1901,8 +1616,12 @@ mod tests { #[test] fn anthropic_thinking_config_goose_prefix_stripped_for_sonnet_5() { // Adaptive xhigh model via goose- prefix. - let (thinking, output_config) = - anthropic_thinking_config("goose-claude-sonnet-5", ThinkingEffort::XHigh, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "goose-claude-sonnet-5", + ThinkingEffort::XHigh, + 32_768, + ); let t = thinking.expect("thinking:{type:adaptive} must be present for goose-claude-sonnet-5"); assert_eq!(t["type"], "adaptive"); @@ -1915,8 +1634,12 @@ mod tests { // team-x-claude-opus-4-7: first claude- token at index 7 → strips "team-x-" // Verifies the arbitrary-prefix normalization reaches anthropic_thinking_config // end-to-end: UI exposes max as valid, and runtime must honor it. - let (thinking, output_config) = - anthropic_thinking_config("team-x-claude-opus-4-7", ThinkingEffort::Max, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "team-x-claude-opus-4-7", + ThinkingEffort::Max, + 32_768, + ); let t = thinking.expect("thinking:{type:adaptive} must be present for team-x-claude-opus-4-7"); assert_eq!(t["type"], "adaptive"); @@ -1937,7 +1660,8 @@ mod tests { "claude-fable-5", "claude-mythos-5", ] { - let (thinking, _) = anthropic_thinking_config(model, ThinkingEffort::High, 32_768); + let (thinking, _) = + anthropic_thinking_config("anthropic", model, ThinkingEffort::High, 32_768); let t = thinking .unwrap_or_else(|| panic!("thinking must be present for adaptive model {model}")); assert_eq!( @@ -1952,7 +1676,8 @@ mod tests { // Manual-budget families (claude-3.x, opus-4-5) must also include // display:"summarized" so thinking text is returned. for model in &["claude-3-7-sonnet-20250219", "claude-opus-4-5"] { - let (thinking, _) = anthropic_thinking_config(model, ThinkingEffort::High, 65_536); + let (thinking, _) = + anthropic_thinking_config("anthropic", model, ThinkingEffort::High, 65_536); let t = thinking.unwrap_or_else(|| { panic!("thinking must be present for manual-budget model {model}") }); @@ -1966,119 +1691,29 @@ mod tests { #[test] fn anthropic_thinking_config_omitted_when_no_thinking_has_no_display_field() { // Models that don't produce a thinking field at all should have no display key. - let (thinking, _) = - anthropic_thinking_config("claude-haiku-4-5", ThinkingEffort::High, 32_768); + let (thinking, _) = anthropic_thinking_config( + "anthropic", + "claude-haiku-4-5", + ThinkingEffort::High, + 32_768, + ); assert!( thinking.is_none(), "thinking must be absent for unknown model" ); } - // ---- clamp_adaptive_effort — per-model clamping tests ---- - - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_opus_4_7() { - // Opus 4.7 supports xhigh — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-7", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_opus_4_8() { - // Opus 4.8 supports xhigh — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-8", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_sonnet_5() { - // Sonnet 5 supports xhigh — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-sonnet-5-20250901", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_clamped_to_high_for_opus_4_6() { - // Opus 4.6 does NOT support xhigh (only low/medium/high/max) — clamp to high. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-6", ThinkingEffort::XHigh), - ThinkingEffort::High - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_clamped_to_high_for_sonnet_4_6() { - // Sonnet 4.6 does NOT support xhigh — clamp to high. - assert_eq!( - clamp_adaptive_effort("claude-sonnet-4-6", ThinkingEffort::XHigh), - ThinkingEffort::High - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_opus_4_6() { - // Opus 4.6 supports max — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-6", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_opus_4_7() { - // Opus 4.7 supports max — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-7", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_opus_4_8() { - // Opus 4.8 supports max — no clamping. - assert_eq!( - clamp_adaptive_effort("claude-opus-4-8", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_low_medium_high_never_clamped() { - // low/medium/high pass through for all adaptive models. - for model in &[ - "claude-opus-4-6", - "claude-opus-4-7", - "claude-opus-4-8", - "claude-sonnet-5-20250901", - "claude-sonnet-4-6", - ] { - for effort in [ - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ] { - assert_eq!( - clamp_adaptive_effort(model, effort), - effort, - "model={model} effort={effort:?}" - ); - } - } - } - // ---- anthropic_thinking_config — xhigh/max body-shape assertions ---- #[test] fn anthropic_thinking_config_opus_4_8_xhigh_emits_xhigh_effort() { // Opus 4.8 supports xhigh; output_config.effort must be "xhigh". - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-8", ThinkingEffort::XHigh, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-opus-4-8", + ThinkingEffort::XHigh, + 32_768, + ); let t = thinking.expect("thinking must be present for claude-opus-4-8"); assert_eq!(t["type"], "adaptive"); let oc = output_config.expect("output_config must be present for claude-opus-4-8"); @@ -2089,7 +1724,7 @@ mod tests { fn anthropic_thinking_config_opus_4_8_max_emits_max_effort() { // Opus 4.8 supports max; output_config.effort must be "max". let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-8", ThinkingEffort::Max, 32_768); + anthropic_thinking_config("anthropic", "claude-opus-4-8", ThinkingEffort::Max, 32_768); let t = thinking.expect("thinking must be present for claude-opus-4-8"); assert_eq!(t["type"], "adaptive"); let oc = output_config.expect("output_config must be present for claude-opus-4-8"); @@ -2099,8 +1734,12 @@ mod tests { #[test] fn anthropic_thinking_config_opus_4_7_xhigh_emits_xhigh_effort() { // Opus 4.7 supports xhigh. - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-7", ThinkingEffort::XHigh, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-opus-4-7", + ThinkingEffort::XHigh, + 32_768, + ); let t = thinking.unwrap(); assert_eq!(t["type"], "adaptive"); let oc = output_config.unwrap(); @@ -2110,8 +1749,12 @@ mod tests { #[test] fn anthropic_thinking_config_opus_4_6_xhigh_clamps_to_high() { // Opus 4.6 does NOT support xhigh → clamp to high. - let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-6", ThinkingEffort::XHigh, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-opus-4-6", + ThinkingEffort::XHigh, + 32_768, + ); let t = thinking.unwrap(); assert_eq!(t["type"], "adaptive"); let oc = output_config.unwrap(); @@ -2125,7 +1768,7 @@ mod tests { fn anthropic_thinking_config_opus_4_6_max_passes_through() { // Opus 4.6 supports max — passes through without clamping. let (thinking, output_config) = - anthropic_thinking_config("claude-opus-4-6", ThinkingEffort::Max, 32_768); + anthropic_thinking_config("anthropic", "claude-opus-4-6", ThinkingEffort::Max, 32_768); let t = thinking.unwrap(); assert_eq!(t["type"], "adaptive"); let oc = output_config.unwrap(); @@ -2137,7 +1780,7 @@ mod tests { // Manual-budget models (claude-3*, opus-4-5): xhigh clamps to high budget (32_768). for model in &["claude-3-7-sonnet-20250219", "claude-opus-4-5"] { let (thinking, output_config) = - anthropic_thinking_config(model, ThinkingEffort::XHigh, 65_536); + anthropic_thinking_config("anthropic", model, ThinkingEffort::XHigh, 65_536); let t = thinking.expect("thinking must be present"); assert_eq!(t["type"], "enabled"); assert_eq!( @@ -2152,7 +1795,7 @@ mod tests { fn anthropic_thinking_config_manual_bucket_max_clamps_to_high_budget() { // Manual-budget models: max also clamps to high budget (32_768). let (thinking, _) = - anthropic_thinking_config("claude-opus-4-5", ThinkingEffort::Max, 65_536); + anthropic_thinking_config("anthropic", "claude-opus-4-5", ThinkingEffort::Max, 65_536); let t = thinking.unwrap(); assert_eq!(t["type"], "enabled"); assert_eq!(t["budget_tokens"], 32_768); @@ -2311,36 +1954,56 @@ mod tests { ); } - // ---- normalize_effort_for_openai_route ---- + // ---- normalize_effort_for_databricks_v2 (F1 exact-record corrections) ---- + + #[test] + fn normalize_effort_for_databricks_v2_gpt_5_5_xhigh_clamps_to_high() { + // F1 correction: databricks-gpt-5-5 supported_efforts = [low, medium, high]. + // XHigh is outside the supported set → nearest supported is High. + assert_eq!( + normalize_effort_for_databricks_v2(ThinkingEffort::XHigh, "databricks-gpt-5-5"), + ThinkingEffort::High, + "databricks-gpt-5-5 XHigh must clamp to High (F1 correction: supported=[low,medium,high])" + ); + } #[test] - fn normalize_openai_route_clamps_max_to_xhigh() { - // Use an unknown model so only the max→xhigh clamp fires, not per-model logic. + fn normalize_effort_for_databricks_v2_gpt_5_5_none_clamps_to_low() { + // F1 correction: databricks-gpt-5-5 supported_efforts = [low, medium, high]. + // None is outside the set → nearest supported is Low. assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::Max, "llama-4"), - ThinkingEffort::XHigh + normalize_effort_for_databricks_v2(ThinkingEffort::None, "databricks-gpt-5-5"), + ThinkingEffort::Low, + "databricks-gpt-5-5 None must clamp to Low (F1 correction: supported=[low,medium,high])" ); } #[test] - fn normalize_openai_route_passes_through_all_other_values_for_unknown_model() { - // Unknown/unverified models pass through unchanged (server-validated). + fn normalize_effort_for_databricks_v2_gpt_5_5_in_range_passes_through() { + // Values within the corrected set must pass through unchanged. for effort in [ - ThinkingEffort::None, - ThinkingEffort::Minimal, ThinkingEffort::Low, ThinkingEffort::Medium, ThinkingEffort::High, - ThinkingEffort::XHigh, ] { assert_eq!( - normalize_effort_for_openai_route(effort, "unknown-future-model"), + normalize_effort_for_databricks_v2(effort, "databricks-gpt-5-5"), effort, - "normalize_effort_for_openai_route must pass through {effort:?} for unknown model" + "databricks-gpt-5-5 {effort:?} is in supported set, must pass through" ); } } + #[test] + fn normalize_effort_for_databricks_v2_gpt_5_6_sol_max_passes_through() { + // databricks-gpt-5-6-sol F1 adoption: [low, medium, high, max] — max is supported. + assert_eq!( + normalize_effort_for_databricks_v2(ThinkingEffort::Max, "databricks-gpt-5-6-sol"), + ThinkingEffort::Max, + "databricks-gpt-5-6-sol Max must pass through (F1: supported includes max)" + ); + } + // ---- normalize_effort_for_anthropic_route ---- #[test] @@ -2384,7 +2047,7 @@ mod tests { fn anthropic_thinking_config_fable_5_emits_adaptive_and_effort() { // Fable 5 — always-on adaptive thinking. let (thinking, output_config) = - anthropic_thinking_config("claude-fable-5", ThinkingEffort::High, 32_768); + anthropic_thinking_config("anthropic", "claude-fable-5", ThinkingEffort::High, 32_768); let t = thinking.expect("thinking must be present for claude-fable-5"); assert_eq!(t["type"], "adaptive"); let oc = output_config.expect("output_config must be present for claude-fable-5"); @@ -2394,8 +2057,12 @@ mod tests { #[test] fn anthropic_thinking_config_mythos_5_emits_adaptive_and_effort() { // Mythos 5 — always-on adaptive thinking. - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-5", ThinkingEffort::Medium, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-mythos-5", + ThinkingEffort::Medium, + 32_768, + ); let t = thinking.expect("thinking must be present for claude-mythos-5"); assert_eq!(t["type"], "adaptive"); let oc = output_config.expect("output_config must be present for claude-mythos-5"); @@ -2405,72 +2072,22 @@ mod tests { #[test] fn anthropic_thinking_config_mythos_preview_emits_adaptive_and_effort() { // Mythos Preview — Always on adaptive thinking. - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-preview", ThinkingEffort::Low, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-mythos-preview", + ThinkingEffort::Low, + 32_768, + ); let t = thinking.expect("thinking must be present for claude-mythos-preview"); assert_eq!(t["type"], "adaptive"); let oc = output_config.expect("output_config must be present for claude-mythos-preview"); assert_eq!(oc["effort"], "low"); } - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_fable_5() { - // Fable 5 supports xhigh. - assert_eq!( - clamp_adaptive_effort("claude-fable-5", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_passes_through_for_mythos_5() { - // Mythos 5 supports xhigh. - assert_eq!( - clamp_adaptive_effort("claude-mythos-5", ThinkingEffort::XHigh), - ThinkingEffort::XHigh - ); - } - - #[test] - fn clamp_adaptive_effort_xhigh_clamped_to_high_for_mythos_preview() { - // Mythos Preview does NOT support xhigh — clamp to high. - assert_eq!( - clamp_adaptive_effort("claude-mythos-preview", ThinkingEffort::XHigh), - ThinkingEffort::High - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_fable_5() { - // Fable 5 supports max. - assert_eq!( - clamp_adaptive_effort("claude-fable-5", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_mythos_5() { - // Mythos 5 supports max. - assert_eq!( - clamp_adaptive_effort("claude-mythos-5", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - - #[test] - fn clamp_adaptive_effort_max_passes_through_for_mythos_preview() { - // Mythos Preview supports max. - assert_eq!( - clamp_adaptive_effort("claude-mythos-preview", ThinkingEffort::Max), - ThinkingEffort::Max - ); - } - #[test] fn anthropic_thinking_config_fable_5_xhigh_emits_xhigh() { let (thinking, output_config) = - anthropic_thinking_config("claude-fable-5", ThinkingEffort::XHigh, 32_768); + anthropic_thinking_config("anthropic", "claude-fable-5", ThinkingEffort::XHigh, 32_768); let t = thinking.unwrap(); assert_eq!(t["type"], "adaptive"); assert_eq!(output_config.unwrap()["effort"], "xhigh"); @@ -2478,8 +2095,12 @@ mod tests { #[test] fn anthropic_thinking_config_mythos_5_xhigh_emits_xhigh() { - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-5", ThinkingEffort::XHigh, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-mythos-5", + ThinkingEffort::XHigh, + 32_768, + ); let t = thinking.unwrap(); assert_eq!(t["type"], "adaptive"); assert_eq!(output_config.unwrap()["effort"], "xhigh"); @@ -2488,8 +2109,12 @@ mod tests { #[test] fn anthropic_thinking_config_mythos_preview_xhigh_clamps_to_high() { // Mythos Preview does NOT support xhigh → clamp to high. - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-preview", ThinkingEffort::XHigh, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-mythos-preview", + ThinkingEffort::XHigh, + 32_768, + ); let t = thinking.unwrap(); assert_eq!(t["type"], "adaptive"); assert_eq!( @@ -2502,7 +2127,7 @@ mod tests { #[test] fn anthropic_thinking_config_fable_5_max_passes_through() { let (thinking, output_config) = - anthropic_thinking_config("claude-fable-5", ThinkingEffort::Max, 32_768); + anthropic_thinking_config("anthropic", "claude-fable-5", ThinkingEffort::Max, 32_768); let t = thinking.unwrap(); assert_eq!(t["type"], "adaptive"); assert_eq!(output_config.unwrap()["effort"], "max"); @@ -2510,527 +2135,17 @@ mod tests { #[test] fn anthropic_thinking_config_mythos_preview_max_passes_through() { - let (thinking, output_config) = - anthropic_thinking_config("claude-mythos-preview", ThinkingEffort::Max, 32_768); + let (thinking, output_config) = anthropic_thinking_config( + "anthropic", + "claude-mythos-preview", + ThinkingEffort::Max, + 32_768, + ); let t = thinking.unwrap(); assert_eq!(t["type"], "adaptive"); assert_eq!(output_config.unwrap()["effort"], "max"); } - // ---- openai_efforts_for_model / normalize_effort_for_openai_route per-model table ---- - - #[test] - fn openai_efforts_for_model_gpt5_pro_high_only() { - // gpt-5-pro: high only — any other value must be substituted. - let supported = openai_efforts_for_model("gpt-5-pro").expect("gpt-5-pro must be in table"); - assert_eq!( - supported, - &[ThinkingEffort::High], - "gpt-5-pro supports only high" - ); - } - - #[test] - fn openai_efforts_for_model_gpt5_6_includes_max() { - let expected: &[ThinkingEffort] = &[ - ThinkingEffort::None, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ThinkingEffort::Max, - ]; - - for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5-6-sol", "goose-gpt-5-6-sol"] { - assert_eq!( - openai_efforts_for_model(model), - Some(expected), - "{model} must match the gpt-5.6 effort table" - ); - } - } - - #[test] - fn openai_efforts_for_model_gpt5_5_includes_xhigh() { - let supported = openai_efforts_for_model("gpt-5.5").expect("gpt-5.5 must be in table"); - assert!( - supported.contains(&ThinkingEffort::XHigh), - "gpt-5.5 must support xhigh" - ); - assert!( - supported.contains(&ThinkingEffort::None), - "gpt-5.5 must support none" - ); - } - - #[test] - fn openai_efforts_for_model_gpt5_1_excludes_xhigh_and_minimal() { - let supported = openai_efforts_for_model("gpt-5.1").expect("gpt-5.1 must be in table"); - assert!( - !supported.contains(&ThinkingEffort::XHigh), - "gpt-5.1 must NOT support xhigh" - ); - assert!( - !supported.contains(&ThinkingEffort::Minimal), - "gpt-5.1 must NOT support minimal" - ); - assert!( - supported.contains(&ThinkingEffort::None), - "gpt-5.1 must support none" - ); - } - - #[test] - fn openai_efforts_for_model_gpt5_base_excludes_none_includes_minimal() { - let supported = openai_efforts_for_model("gpt-5").expect("gpt-5 base must be in table"); - assert!( - !supported.contains(&ThinkingEffort::None), - "gpt-5 base must NOT support none" - ); - assert!( - supported.contains(&ThinkingEffort::Minimal), - "gpt-5 base must support minimal" - ); - } - - #[test] - fn openai_efforts_for_model_unknown_returns_none() { - // Unknown models are not doc-verified — caller treats as server-validated pass-through. - assert!(openai_efforts_for_model("llama-4").is_none()); - assert!(openai_efforts_for_model("claude-opus-4-8").is_none()); - assert!(openai_efforts_for_model("gpt-4o").is_none()); - } - - // ---- Boundary-safe matching: version digits must not false-match longer versions ---- - - #[test] - fn openai_efforts_for_model_boundary_dated_base_ids_are_not_versioned() { - // gpt-5-1106: the "-1" is not version 5.1 — it's a date segment on the base model. - // Must fall through to base table, not gpt-5.1. - let result = openai_efforts_for_model("gpt-5-1106"); - let base = openai_efforts_for_model("gpt-5").unwrap(); - assert_eq!( - result, - Some(base), - "gpt-5-1106 must match base table (not gpt-5.1): got {result:?}" - ); - // Crucially, must NOT support None (that's a gpt-5.1 property, not base). - assert!( - !result.unwrap().contains(&ThinkingEffort::None), - "gpt-5-1106 must NOT support none — base table only has minimal" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_gpt5_4o_is_base_not_5_4() { - // gpt-5-4o: the "-4" could false-match the gpt-5.4 family, but "4o" is a - // capability suffix on the base gpt-5 model, not version 5.4. - // Must fall through to base table. - let result = openai_efforts_for_model("gpt-5-4o"); - let base = openai_efforts_for_model("gpt-5").unwrap(); - assert_eq!( - result, - Some(base), - "gpt-5-4o must match base table (not gpt-5.4): got {result:?}" - ); - // Crucially, must NOT support XHigh (that's a gpt-5.4 property, not base). - assert!( - !result.unwrap().contains(&ThinkingEffort::XHigh), - "gpt-5-4o must NOT support xhigh — that's a gpt-5.4 property and would 400" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_multi_digit_versions_pass_through() { - // Dotted two-digit versions (gpt-5.10, gpt5.10, gpt-5.50) must not match any known - // single-digit family — the digit boundary check on dotted tokens blocks them. - // These return None (server-validated pass-through). - assert!( - openai_efforts_for_model("gpt-5.10").is_none(), - "gpt-5.10 must pass through (unknown future model)" - ); - assert!( - openai_efforts_for_model("gpt5.10").is_none(), - "gpt5.10 must pass through (unknown future model)" - ); - assert!( - openai_efforts_for_model("gpt-5.50").is_none(), - "gpt-5.50 must pass through (not gpt-5.5)" - ); - // Dash two-digit versions (gpt-5-10, databricks-gpt-5-10) look like short numeric - // version segments and must also pass through as unknown — not bucketed as base. - assert!( - openai_efforts_for_model("gpt-5-10").is_none(), - "gpt-5-10 must pass through (short numeric suffix = potential unrecognized version)" - ); - assert!( - openai_efforts_for_model("databricks-gpt-5-10").is_none(), - "databricks-gpt-5-10 must pass through (short numeric suffix)" - ); - // Short numeric suffix + textual continuation (e.g. a hypothetical 'gpt-5.10-preview') - // must also pass through — the digit count (1-3) determines version-like, regardless of - // what follows. - assert!( - openai_efforts_for_model("gpt-5-10-preview").is_none(), - "gpt-5-10-preview must pass through (short numeric version suffix with text tail)" - ); - assert!( - openai_efforts_for_model("databricks-gpt-5-10-preview").is_none(), - "databricks-gpt-5-10-preview must pass through (short numeric version suffix with text tail)" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_date_segment_with_suffix_is_base() { - // 4+ digit date segment followed by a textual suffix must still resolve to the base - // table — the date length (>=4) determines it's a build/date, not a version number. - let result = openai_efforts_for_model("gpt-5-1106-preview"); - assert!( - result.is_some(), - "gpt-5-1106-preview must match base table (4-digit date segment)" - ); - let supported = result.unwrap(); - assert!( - supported.contains(&ThinkingEffort::Minimal), - "gpt-5-1106-preview (base) must support minimal" - ); - assert!( - !supported.contains(&ThinkingEffort::None), - "gpt-5-1106-preview (base) must NOT support none" - ); - assert!( - !supported.contains(&ThinkingEffort::XHigh), - "gpt-5-1106-preview (base) must NOT support xhigh" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_databricks_prefixed_still_matches() { - // Databricks-prefixed names (gateway forwarding) must still resolve to the right table. - let result = openai_efforts_for_model("databricks-gpt-5-5"); - assert_eq!( - result, - openai_efforts_for_model("gpt-5.5"), - "databricks-gpt-5-5 must match gpt-5.5 family table" - ); - } - - #[test] - fn openai_efforts_for_model_boundary_date_suffixed_still_matches() { - // Date-suffixed names (e.g. gpt-5.1-2025-04-01) must still resolve to the right family. - let result = openai_efforts_for_model("gpt-5.1-2025-04-01"); - assert_eq!( - result, - openai_efforts_for_model("gpt-5.1"), - "gpt-5.1-2025-04-01 must match gpt-5.1 family table" - ); - } - - #[test] - fn openai_efforts_for_model_pro_before_base_gpt5() { - // gpt-5-pro must match the -pro table, not the base gpt-5 table. - let pro = openai_efforts_for_model("gpt-5-pro").unwrap(); - let base = openai_efforts_for_model("gpt-5").unwrap(); - assert_ne!( - pro, base, - "gpt-5-pro and gpt-5 base must hit different table entries" - ); - assert_eq!(pro, &[ThinkingEffort::High]); - } - - #[test] - fn normalize_openai_route_gpt5_pro_high_passes_through() { - // gpt-5-pro: high is the only supported value → high passes through unchanged. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::High, "gpt-5-pro"), - ThinkingEffort::High - ); - } - - #[test] - fn normalize_openai_route_gpt5_pro_anything_but_high_becomes_high() { - // gpt-5-pro: any effort other than high must resolve to high. - for effort in [ - ThinkingEffort::None, - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::XHigh, - ] { - assert_eq!( - normalize_effort_for_openai_route(effort, "gpt-5-pro"), - ThinkingEffort::High, - "gpt-5-pro: {effort:?} must resolve to high" - ); - } - } - - #[test] - fn normalize_openai_route_gpt5_base_none_becomes_minimal() { - // gpt-5 base supports minimal but not none. none → minimal (peer fallback). - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::None, "gpt-5"), - ThinkingEffort::Minimal, - "gpt-5 base: none must fall back to minimal (peer)" - ); - } - - #[test] - fn normalize_openai_route_passes_max_through_for_gpt5_6() { - for model in ["gpt-5.6", "gpt-5-6-sol"] { - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::Max, model), - ThinkingEffort::Max, - "{model} must preserve max" - ); - } - } - - #[test] - fn normalize_openai_route_gpt5_5_max_becomes_xhigh() { - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.5"), - ThinkingEffort::XHigh, - "gpt-5.5 must clamp max to xhigh" - ); - } - - #[test] - fn normalize_openai_route_gpt5_5_minimal_becomes_none() { - // gpt-5.5 supports none but not minimal. minimal → none (peer fallback). - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::Minimal, "gpt-5.5"), - ThinkingEffort::None, - "gpt-5.5: minimal must fall back to none (peer)" - ); - } - - #[test] - fn normalize_openai_route_gpt5_1_xhigh_becomes_high() { - // gpt-5.1 does not support xhigh → nearest supported below xhigh is high. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::XHigh, "gpt-5.1"), - ThinkingEffort::High, - "gpt-5.1: xhigh must resolve to high" - ); - } - - #[test] - fn normalize_openai_route_gpt5_4_xhigh_passes_through() { - // gpt-5.4 supports xhigh → pass through unchanged. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::XHigh, "gpt-5.4"), - ThinkingEffort::XHigh - ); - } - - #[test] - fn normalize_openai_route_gpt5_5_xhigh_passes_through() { - // gpt-5.5 supports xhigh → pass through unchanged. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::XHigh, "gpt-5.5"), - ThinkingEffort::XHigh - ); - } - - #[test] - fn normalize_openai_route_gpt5_dash_suffix_variants_match_correctly() { - // Databricks-prefixed or date-suffixed names must still hit the right family. - // "gpt-5.5" and "gpt-5-5" are treated identically; ditto for other families. - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::XHigh, "gpt-5-5"), - ThinkingEffort::XHigh, - "gpt-5-5 (dash) must match gpt-5.5 table" - ); - assert_eq!( - normalize_effort_for_openai_route(ThinkingEffort::None, "gpt-5-1"), - ThinkingEffort::None, - "gpt-5-1 (dash) must match gpt-5.1 table" - ); - } - - #[test] - fn normalize_openai_route_unknown_model_passthrough() { - // Unknown models: all values pass through without substitution (server-validated). - for effort in [ - ThinkingEffort::None, - ThinkingEffort::Minimal, - ThinkingEffort::Low, - ThinkingEffort::Medium, - ThinkingEffort::High, - ThinkingEffort::XHigh, - ] { - assert_eq!( - normalize_effort_for_openai_route(effort, "llama-4"), - effort, - "unknown model: {effort:?} must pass through unchanged" - ); - } - } - - // ---- effort-table fixture sync guard ---------------------------------------- - // - // Loads `effortTable.fixture.json` (the single source of truth shared with - // the TS test in `buzzAgentConfig.test.mjs`) and verifies that this Rust - // implementation produces the same valid-effort-value sets and default values - // as the TS `getProviderEffortConfig` function. - // - // Drift (a new model family added to one side but not the other) fails CI here - // before it can silently diverge in production. - // ───────────────────────────────────────────────────────────────────────────── - - /// Compute the valid effort values for a provider/model pair, mirroring - /// `getProviderEffortConfig` in `buzzAgentConfig.ts`. - /// - /// Returns `(valid_values, default_value)` where `default_value` is `None` - /// for Anthropic manual-budget models (TS `defaultValue: null`), otherwise - /// `Some("medium")` or `Some("high")`. - fn valid_effort_values_for_provider_model( - provider: &str, - model: &str, - ) -> (Vec<&'static str>, Option<&'static str>) { - const ALL_7: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh", "max"]; - const ALL_EXCEPT_MAX: &[&str] = &["none", "minimal", "low", "medium", "high", "xhigh"]; - const GPT5_PRO: &[&str] = &["high"]; - const GPT5_1: &[&str] = &["none", "low", "medium", "high"]; - - let p = provider.to_ascii_lowercase(); - // Strip arbitrary endpoint-naming prefix before model matching, mirroring TS and - // strip_catalog_prefix: find the first known family token (claude-, gpt-) and - // drop everything before it. Handles any catalog naming convention. - let raw_model = model.trim(); - let lower_raw = raw_model.to_ascii_lowercase(); - const FAMILY_TOKENS: &[&str] = &["claude-", "gpt-"]; - let first_idx = FAMILY_TOKENS - .iter() - .filter_map(|tok| lower_raw.find(tok)) - .min(); - let stripped = match first_idx { - Some(idx) => &raw_model[idx..], - None => raw_model, - }; - let m = stripped.to_ascii_lowercase(); - - // Thin adapter: converts production helper output to the string-based - // return type used by this function. - fn anthropic_result(m: &str) -> (Vec<&'static str>, Option<&'static str>) { - let (values, default) = anthropic_efforts_for_model(m); - let strs: Vec<&'static str> = values.iter().map(|e| e.openai_effort_str()).collect(); - (strs, default.map(|e| e.openai_effort_str())) - } - - fn openai_result(m: &str) -> (Vec<&'static str>, Option<&'static str>) { - if let Some(values) = openai_efforts_for_model(m) { - let strs: Vec<&'static str> = - values.iter().map(|e| e.openai_effort_str()).collect(); - // Determine default from the family. - let default_val = if strs == GPT5_PRO { - Some("high") - } else if strs == GPT5_1 { - Some("none") - } else { - Some("medium") - }; - (strs, default_val) - } else { - // Unknown model → all-except-max, default medium. - (ALL_EXCEPT_MAX.to_vec(), Some("medium")) - } - } - - if p == "anthropic" { - return anthropic_result(&m); - } - if p == "openai" { - return openai_result(&m); - } - if p == "databricks_v2" { - if m.starts_with("claude-") { - return anthropic_result(&m); - } - // gpt-5 family check mirrors gpt5FamilyModel in TS. - let is_gpt5 = gpt5_token_matches(&m, "gpt-5-pro") - || gpt5_token_matches(&m, "gpt5-pro") - || gpt5_token_matches(&m, "gpt-5.6") - || gpt5_token_matches(&m, "gpt5.6") - || gpt5_token_matches(&m, "gpt-5-6") - || gpt5_token_matches(&m, "gpt5-6") - || gpt5_token_matches(&m, "gpt-5.5") - || gpt5_token_matches(&m, "gpt5.5") - || gpt5_token_matches(&m, "gpt-5.4") - || gpt5_token_matches(&m, "gpt5.4") - || gpt5_token_matches(&m, "gpt-5.1") - || gpt5_token_matches(&m, "gpt5.1") - || gpt5_base_matches(&m, "gpt-5") - || gpt5_base_matches(&m, "gpt5"); - if is_gpt5 { - return openai_result(&m); - } - if !m.is_empty() { - // Concrete non-claude, non-gpt5: MLflow path → all-except-max. - return openai_result(&m); - } - // Blank model: route unknown, all-7. - return (ALL_7.to_vec(), Some("medium")); - } - if p == "databricks" { - return openai_result(&m); - } - if p == "openrouter" { - return (ALL_7.to_vec(), Some("medium")); - } - // openai-compat, unknown, empty → all-7, default medium. - (ALL_7.to_vec(), Some("medium")) - } - - #[derive(serde::Deserialize)] - struct FixtureEntry { - note: Option, - provider: String, - model: String, - #[serde(rename = "validValues")] - valid_values: Vec, - #[serde(rename = "defaultValue")] - default_value: Option, - } - - #[test] - fn effort_table_fixture_matches_rust_implementation() { - let fixture_json = - include_str!("../../../desktop/src/features/agents/ui/effortTable.fixture.json"); - let entries: Vec = - serde_json::from_str(fixture_json).expect("fixture must be valid JSON"); - - assert!( - !entries.is_empty(), - "fixture must contain at least one entry" - ); - - for entry in &entries { - let label = entry.note.as_deref().unwrap_or(entry.model.as_str()); - let (valid_values, default_value) = - valid_effort_values_for_provider_model(&entry.provider, &entry.model); - - let expected: Vec<&str> = entry.valid_values.iter().map(String::as_str).collect(); - assert_eq!( - valid_values, expected, - "validValues mismatch for fixture entry \"{label}\" \ - (provider={}, model={}): Rust side has {valid_values:?}, \ - fixture expects {expected:?}", - entry.provider, entry.model, - ); - - let expected_default: Option<&str> = entry.default_value.as_deref(); - assert_eq!( - default_value, expected_default, - "defaultValue mismatch for fixture entry \"{label}\" \ - (provider={}, model={}): Rust side has {default_value:?}, \ - fixture expects {expected_default:?}", - entry.provider, entry.model, - ); - } - } - #[test] fn resolve_provider_openrouter_with_key() { assert_eq!( diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 3e4ee3cd527..98fa99ca5bf 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -8,10 +8,11 @@ mod handoff; mod hints; mod llm; mod mcp; +pub mod model_capabilities; pub mod types; mod wire; -pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; +pub use catalog::{discover_databricks_models, ModelEntry}; pub use config::Provider; pub use types::AgentError; @@ -339,12 +340,15 @@ async fn resolve_models_catalog( /// /// This value is never written to `models_cache`; failed discovery must be retried by /// the next session rather than pinning degraded state for the process lifetime. +/// +/// Only reached from the Databricks provider arm below, so the curated label is +/// looked up from the Databricks manifest; `id` stays the raw configured value. fn configured_model_fallback(model: &str) -> Vec { let model = model.trim().to_string(); - vec![ModelEntry { - id: model.clone(), - name: model, - }] + let name = crate::model_capabilities::databricks_registry_label(&model) + .unwrap_or(&model) + .to_string(); + vec![ModelEntry { id: model, name }] } async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { @@ -1010,6 +1014,7 @@ mod tests { #[test] fn configured_model_fallback_is_trimmed_and_singular() { + // Unknown id: trimmed, and the raw id passes through as the name. assert_eq!( crate::configured_model_fallback(" configured-model "), vec![ModelEntry { @@ -1018,4 +1023,17 @@ mod tests { }] ); } + + #[test] + fn configured_model_fallback_curates_known_databricks_id() { + // A configured Databricks id known to the manifest gets its curated + // label; `id` stays the raw wire/config value. + assert_eq!( + crate::configured_model_fallback("databricks-gpt-5-5"), + vec![ModelEntry { + id: "databricks-gpt-5-5".into(), + name: "GPT-5.5".into(), + }] + ); + } } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index a963de1e7c1..47df56a6d37 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -7,8 +7,8 @@ use serde_json::{json, Map, Value}; use crate::auth::{PkceOAuthConfig, PkceOAuthTokenSource, StaticTokenSource, TokenSource}; use crate::config::{ - is_openai_host, normalize_effort_for_anthropic_route, normalize_effort_for_openai_route, - Config, OpenAiApi, Provider, ThinkingEffort, + is_openai_host, normalize_effort_for_anthropic_route, normalize_effort_for_databricks_v2, + normalize_effort_for_provider, Config, OpenAiApi, Provider, ThinkingEffort, }; use crate::types::{ AgentError, HistoryItem, LlmResponse, ProviderStop, ToolCall, ToolDef, ToolResultContent, @@ -89,7 +89,15 @@ impl Llm { Provider::Anthropic => self .post_anthropic( cfg, - &anthropic_body(cfg, system_prompt, history, tools, effective_model, effort), + &anthropic_body( + cfg, + system_prompt, + history, + tools, + effective_model, + effort, + "anthropic", + ), ) .await .and_then(parse_anthropic), @@ -107,12 +115,19 @@ impl Llm { .and_then(parse_openai_with_reasoning_details) } Provider::OpenAi | Provider::Databricks => { + let provider_str = match cfg.provider { + Provider::OpenAi => "openai", + Provider::Databricks => "databricks", + _ => unreachable!(), + }; self.openai_request(cfg, effective_model, |use_responses, request_model| { - // Normalize effort for model-specific availability. Startup no longer rejects - // `max` for pure OpenAI/Databricks; this per-model table is the single authority - // — it keeps `max` for gpt-5.6, clamps `max`→`xhigh` for other OpenAI-shaped - // models, and still applies corrections like none→minimal on the gpt-5 base. - let e = effort.map(|ef| normalize_effort_for_openai_route(ef, request_model)); + // Normalize effort via the manifest: resolve the actual provider/model + // record and apply resolve_openai_effort over its supported_efforts. + // Adopted exact-record corrections (e.g. databricks-gpt-5-4-mini → + // [low,medium,high]) are enforced here; the openai fallback's effort set + // carries the former "unknown model: max→xhigh, others pass" behavior. + let e = effort + .map(|ef| normalize_effort_for_provider(provider_str, request_model, ef)); if use_responses { ( responses_body(cfg, system_prompt, history, tools, request_model, e), @@ -130,9 +145,9 @@ impl Llm { Provider::DatabricksV2 => { self.databricks_v2_request(cfg, effective_model, |route| match route { DatabricksV2Route::OpenAiResponses => { - // OpenAI Responses path: normalize effort against the per-model table. - let e = - effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model)); + // OpenAI Responses path: normalize effort via manifest normalization_policy. + let e = effort + .map(|ef| normalize_effort_for_databricks_v2(ef, effective_model)); ( responses_body(cfg, system_prompt, history, tools, effective_model, e), parse_responses as OpenAiParse, @@ -142,14 +157,22 @@ impl Llm { // Anthropic Messages path: normalize effort (none|minimal → omit). let e = effort.and_then(normalize_effort_for_anthropic_route); ( - anthropic_body(cfg, system_prompt, history, tools, effective_model, e), + anthropic_body( + cfg, + system_prompt, + history, + tools, + effective_model, + e, + "databricks_v2", + ), parse_anthropic as OpenAiParse, ) } DatabricksV2Route::MlflowChatCompletions => { - // MLflow Chat path (OpenAI-shaped): normalize effort against the per-model table. - let e = - effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model)); + // MLflow Chat path (OpenAI-shaped): normalize effort via manifest. + let e = effort + .map(|ef| normalize_effort_for_databricks_v2(ef, effective_model)); ( openai_body(cfg, system_prompt, history, tools, effective_model, e), parse_openai as OpenAiParse, @@ -425,7 +448,7 @@ impl Llm { where F: FnOnce(DatabricksV2Route) -> (Value, OpenAiParse) + Send, { - let route = databricks_v2_route_for_model(effective_model); + let route = databricks_v2_route(effective_model); let (body, parse) = build(route); parse( self.post_openai(cfg, databricks_v2_path(route), &body, effective_model) @@ -545,6 +568,7 @@ fn anthropic_body( tools: &[ToolDef], effective_model: &str, effort: Option, + provider: &str, ) -> Value { let mut messages: Vec = Vec::new(); let mut pending: Vec = Vec::new(); @@ -616,8 +640,12 @@ fn anthropic_body( let mut body = json!({ "model": effective_model, "max_tokens": cfg.max_output_tokens, "system": system_value, "messages": messages }); if let Some(e) = effort { - let (thinking, output_config) = - crate::config::anthropic_thinking_config(effective_model, e, cfg.max_output_tokens); + let (thinking, output_config) = crate::config::anthropic_thinking_config( + provider, + effective_model, + e, + cfg.max_output_tokens, + ); if let Some(t) = thinking { body["thinking"] = t; } @@ -938,56 +966,33 @@ fn is_responses_required_error(body: &str) -> bool { || b.contains("use the responses api") } -/// OpenAI-family code names that appear as their own segment in a Databricks v2 -/// endpoint name (the GPT-5 launch aliases). The `gpt` family itself is matched -/// separately by segment prefix so `gpt`, `gpt5`, and the `gpt` of a split -/// `gpt-5` all qualify. -const DATABRICKS_V2_OPENAI_CODE_NAMES: &[&str] = &["sol", "luna", "terra"]; - -/// Anthropic (Claude) family and release code names that appear as their own -/// segment in a Databricks v2 endpoint name — the `claude` prefix, the family -/// names (`opus`, `sonnet`, `haiku`), and the release code names (`mythos`, -/// `fable`). Getting a Claude model onto the Anthropic Messages route is what -/// lets it carry a `cache_control` breakpoint; an endpoint that matches none of -/// these falls through to the MLflow (OpenAI-wire) path, where Anthropic prompt -/// caching is structurally impossible and the discount is silently lost. -const DATABRICKS_V2_CLAUDE_NAMES: &[&str] = - &["claude", "opus", "sonnet", "haiku", "mythos", "fable"]; - -/// Split a Databricks v2 endpoint name into its lowercase alphanumeric segments, -/// breaking on any non-alphanumeric delimiter (`-`, `_`, `.`, `/`, …). E.g. -/// `Databricks-Claude-Opus-5` -> `["databricks", "claude", "opus", "5"]`. -fn model_name_segments(model: &str) -> Vec { - model - .split(|c: char| !c.is_ascii_alphanumeric()) - .filter(|s| !s.is_empty()) - .map(str::to_ascii_lowercase) - .collect() -} - -fn databricks_v2_route_for_model(model: &str) -> DatabricksV2Route { - // The v2 catalog exposes no family field, so the wire format is inferred - // from the endpoint name. Discovery deliberately keeps arbitrary custom - // aliases, so we match whole name *segments* rather than raw substrings: a - // substring test would misroute unrelated names — `consolidated-llama` - // (`sol`), `terraform-coder` (`terra`), `corpus-reranker`/`octopus-model` - // (`opus`) — onto a wire whose request shape their backend can't parse, - // turning a caching optimization into a hard request/parse failure. Segment - // matching still accepts real prefixed names like `goose-opus-5`. - let segments = model_name_segments(model); - let has_named_segment = - |names: &[&str]| segments.iter().any(|seg| names.contains(&seg.as_str())); - // `gpt` family: any segment beginning with `gpt` — covers `gpt`, `gpt5`, and - // the `gpt` segment of a split `gpt-5`, without matching mid-word. - let is_gpt_family = segments.iter().any(|seg| seg.starts_with("gpt")); - // OpenAI is checked before Claude so a name carrying both markers resolves - // to the OpenAI wire (preserving the prior `gpt-5`-first precedence). - if is_gpt_family || has_named_segment(DATABRICKS_V2_OPENAI_CODE_NAMES) { - DatabricksV2Route::OpenAiResponses - } else if has_named_segment(DATABRICKS_V2_CLAUDE_NAMES) { - DatabricksV2Route::AnthropicMessages - } else { - DatabricksV2Route::MlflowChatCompletions +/// Resolve the Databricks v2 AI Gateway wire route for `model` from the manifest. +/// +/// The route is a capability of the `(databricks_v2, model)` pair, owned by +/// `scripts/model-capabilities.json` and resolved by the shared interpreter — the +/// same authority that drives effort/label resolution. This function only maps the +/// manifest's route enum onto the three concrete wire routes this dispatch path can +/// serve; it holds no routing knowledge of its own. +/// +/// The manifest enum carries two non-wire variants that cannot occur here for a +/// concrete Databricks v2 model at dispatch time: +/// - `NotApplicable` is produced only for non-`databricks_v2` providers, and this +/// seam is reached only under `Provider::DatabricksV2`. +/// - `RouteUnknown` is produced only for a blank model id, which `Config` rejects at +/// startup (`DATABRICKS_MODEL` required) and `session/set_model` rejects at runtime +/// (empty `modelId` → `invalid_params`), so `effective_model` is never blank here. +/// +/// Both are folded into `MlflowChatCompletions` — the manifest's own concrete-unknown +/// fallback and the route a blank id would historically have taken — so an unforeseen +/// reshape degrades to the safe OpenAI-wire route rather than panicking. +fn databricks_v2_route(model: &str) -> DatabricksV2Route { + use crate::model_capabilities::DatabricksV2Route as Manifest; + match crate::model_capabilities::resolve("databricks_v2", model).databricks_v2_wire_route { + Manifest::OpenaiResponses => DatabricksV2Route::OpenAiResponses, + Manifest::AnthropicMessages => DatabricksV2Route::AnthropicMessages, + Manifest::MlflowChat | Manifest::NotApplicable | Manifest::RouteUnknown => { + DatabricksV2Route::MlflowChatCompletions + } } } @@ -2860,6 +2865,7 @@ mod tests { &[], "model", None, + "anthropic", ); let content = &body["messages"][2]["content"][0]["content"]; assert_eq!(content[0]["type"], "text"); @@ -3144,8 +3150,13 @@ mod tests { } #[test] - fn databricks_v2_routes_by_model_family() { + fn databricks_v2_dispatch_routes_from_manifest() { use DatabricksV2Route::{AnthropicMessages, MlflowChatCompletions, OpenAiResponses}; + // Exercises the production dispatch seam (`databricks_v2_route` + + // `databricks_v2_path`), not the interpreter — these are the exact + // functions `databricks_v2_request` calls to pick a wire. Expected + // values are the manifest-ratified answers (corpus class F et al.), so + // this is the wire-visible contract, not a restatement of the resolver. for (model, route, path) in [ // OpenAI-shaped: the gpt family plus the GPT-5 code names. ( @@ -3153,9 +3164,6 @@ mod tests { OpenAiResponses, "/ai-gateway/openai/v1/responses", ), - ("gpt-4o", OpenAiResponses, "/ai-gateway/openai/v1/responses"), - // The intentional dashless `gpt5` spelling still routes to OpenAI. - ("gpt5", OpenAiResponses, "/ai-gateway/openai/v1/responses"), ( "databricks-gpt-5-6-luna", OpenAiResponses, @@ -3166,66 +3174,43 @@ mod tests { OpenAiResponses, "/ai-gateway/openai/v1/responses", ), - ( - "databricks-terra", - OpenAiResponses, - "/ai-gateway/openai/v1/responses", - ), - // Anthropic-shaped: the claude prefix, the family names, and the - // release code names — each must reach the cache-capable route even - // when the endpoint name omits the literal "claude". + // Anthropic-shaped: curated `databricks-claude-*` names keep the + // cache-capable Messages wire via the manifest prefix rule. ( "databricks-claude-opus-4-7", AnthropicMessages, "/ai-gateway/anthropic/v1/messages", ), ( - "goose-opus-5", - AnthropicMessages, - "/ai-gateway/anthropic/v1/messages", - ), - ( - "databricks-sonnet-5", - AnthropicMessages, - "/ai-gateway/anthropic/v1/messages", - ), - ( - "databricks-haiku-4-5", - AnthropicMessages, - "/ai-gateway/anthropic/v1/messages", - ), - ( - "databricks-mythos-5", - AnthropicMessages, - "/ai-gateway/anthropic/v1/messages", - ), - ( - "databricks-fable-5", + "Databricks-Claude-Opus-5", AnthropicMessages, "/ai-gateway/anthropic/v1/messages", ), - // Case-insensitive. + // WIRE-VISIBLE CHANGE (corpus class F): an *uncurated* Claude + // code-name endpoint no longer routes to Anthropic Messages. The + // legacy segment classifier sent `goose-opus-5` to the cache-capable + // wire off the bare `opus` segment; the manifest treats only + // curated `databricks-claude-*` / exact records as Anthropic, so + // bare code names fall to the MLflow chat route (losing Anthropic + // prompt caching on those names). See the mutation guard below. ( - "Databricks-Claude-Opus-5", - AnthropicMessages, - "/ai-gateway/anthropic/v1/messages", + "goose-opus-5", + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", ), - // Unrecognised names still fall through to the MLflow chat route. ( - "custom-tool-model", + "opus-5", MlflowChatCompletions, "/ai-gateway/mlflow/v1/chat/completions", ), + // Unrecognised names still fall through to the MLflow chat route. ( - "databricks-gemini-3-pro", + "custom-tool-model", MlflowChatCompletions, "/ai-gateway/mlflow/v1/chat/completions", ), // Collision guard: short code names must match only as whole // segments, never as substrings of an unrelated custom alias. - // Each of these embeds a marker (`sol`, `terra`, `opus`) mid-word - // and must stay on the MLflow fallback, not adopt a wire its - // backend can't parse. ( "consolidated-llama", MlflowChatCompletions, @@ -3247,12 +3232,65 @@ mod tests { "/ai-gateway/mlflow/v1/chat/completions", ), ] { - let got = databricks_v2_route_for_model(model); + let got = databricks_v2_route(model); assert_eq!(got, route, "model={model}"); assert_eq!(databricks_v2_path(got), path, "model={model}"); } } + #[test] + fn databricks_v2_dispatch_is_pure_manifest_projection() { + // Mutation-bypass guard: the dispatch seam must be a pure projection of + // the manifest's resolved `databricks_v2_wire_route`, with no routing + // decision of its own. For every known DBv2 model (plus the legacy + // collision-guard and code-name cases), the seam's wire choice must + // equal the enum mapping of `resolve(...).databricks_v2_wire_route`. + // + // Reintroducing the deleted segment classifier — or any `if + // model.contains("opus")`-style shortcut that bypasses the manifest — + // disagrees with the manifest on `goose-opus-5` (segment → Anthropic, + // manifest → MLflow) and fails this test. + use crate::model_capabilities::{resolve, DatabricksV2Route as Manifest}; + let expected = |model: &str| match resolve("databricks_v2", model).databricks_v2_wire_route + { + Manifest::OpenaiResponses => DatabricksV2Route::OpenAiResponses, + Manifest::AnthropicMessages => DatabricksV2Route::AnthropicMessages, + Manifest::MlflowChat | Manifest::NotApplicable | Manifest::RouteUnknown => { + DatabricksV2Route::MlflowChatCompletions + } + }; + let mut models: Vec = + crate::model_capabilities::databricks_v2_known_models().to_vec(); + // Uncurated / adversarial names the known-model list does not carry, so + // the guard covers the exact inputs the legacy classifier misrouted. + for extra in [ + "goose-opus-5", + "opus-5", + "goose-claude-fable-5", + "consolidated-llama", + "terraform-coder", + "corpus-reranker", + "octopus-model", + "gpt-opus-5", + ] { + models.push(extra.to_string()); + } + for model in &models { + assert_eq!( + databricks_v2_route(model), + expected(model), + "dispatch seam diverged from manifest authority for model={model}" + ); + } + // The class-F case, stated as a hard fact so the guard's intent is + // legible: the manifest routes `goose-opus-5` to the MLflow wire, and + // the seam agrees — the legacy Anthropic answer is gone. + assert_eq!( + databricks_v2_route("goose-opus-5"), + DatabricksV2Route::MlflowChatCompletions + ); + } + #[test] fn parse_responses_rejects_malformed_function_arguments() { let v = serde_json::json!({ @@ -3394,6 +3432,7 @@ mod tests { &[], "databricks-claude-opus-5", None, + "databricks_v2", ); // Static prefix: system promoted to a structured block carrying the marker. assert_eq!(body["system"][0]["type"], "text"); @@ -3424,6 +3463,7 @@ mod tests { &[], "databricks-claude-opus-5", None, + "databricks_v2", ); let msgs = body["messages"].as_array().unwrap(); assert_eq!(msgs.len(), 1); @@ -3444,6 +3484,7 @@ mod tests { &[], "databricks-claude-opus-5", None, + "databricks_v2", ); // system stays a bare string; no marker anywhere. assert_eq!(body["system"], "sys"); @@ -3462,6 +3503,7 @@ mod tests { &[], "databricks-claude-opus-5", None, + "databricks_v2", ); assert_eq!(body["system"], ""); assert_eq!( @@ -3481,6 +3523,7 @@ mod tests { &[], "model", None, + "anthropic", ); assert!( body.get("thinking").is_none(), @@ -3501,6 +3544,7 @@ mod tests { &[], "claude-3-7-sonnet-20250219", Some(ThinkingEffort::High), + "anthropic", ); assert_eq!(body["thinking"]["type"], "enabled"); // budget_tokens = min(32768, 4096-1024) = 3072 @@ -3520,6 +3564,7 @@ mod tests { &[], "claude-3-7-sonnet-20250219", Some(ThinkingEffort::High), + "anthropic", ); assert!( body.get("thinking").is_none(), @@ -3539,6 +3584,7 @@ mod tests { &[], "claude-3-7-sonnet-20250219", Some(ThinkingEffort::High), + "anthropic", ); let t = body .get("thinking") @@ -3558,6 +3604,7 @@ mod tests { &[], "claude-3-7-sonnet-20250219", Some(ThinkingEffort::High), + "anthropic", ); assert_eq!(body["thinking"]["budget_tokens"], 32_768); } @@ -3575,6 +3622,7 @@ mod tests { &[], "claude-3-7-sonnet-20250219", Some(ThinkingEffort::Low), + "anthropic", ); // Low budget (1024) fits exactly at the boundary — emitted without capping. assert_eq!(body["thinking"]["budget_tokens"], 1024); @@ -3593,6 +3641,7 @@ mod tests { &[], "claude-opus-4-7", Some(ThinkingEffort::High), + "anthropic", ); assert_eq!( body["thinking"]["type"], "adaptive", @@ -3614,6 +3663,7 @@ mod tests { &[], "claude-opus-4-5", Some(ThinkingEffort::High), + "anthropic", ); assert_eq!(body["thinking"]["type"], "enabled"); assert_eq!(body["thinking"]["budget_tokens"], 31_744); // min(32768, 32768-1024) @@ -3633,6 +3683,7 @@ mod tests { &[], "gpt-4o", Some(ThinkingEffort::High), + "anthropic", ); assert!(body.get("thinking").is_none(), "thinking must be absent"); assert!( @@ -3777,6 +3828,7 @@ mod tests { &[], "override-model", None, + "anthropic", ); assert_eq!(body["model"], "override-model"); } @@ -3806,6 +3858,7 @@ mod tests { &[], "claude-opus-4-8", Some(ThinkingEffort::XHigh), + "anthropic", ); assert_eq!(body["thinking"]["type"], "adaptive"); assert_eq!(body["output_config"]["effort"], "xhigh"); @@ -3823,6 +3876,7 @@ mod tests { &[], "claude-opus-4-8", Some(ThinkingEffort::Max), + "anthropic", ); assert_eq!(body["thinking"]["type"], "adaptive"); assert_eq!(body["output_config"]["effort"], "max"); @@ -3886,17 +3940,17 @@ mod tests { // ---- DatabricksV2 route-aware effort normalization (body-level assertions) ---- // - // The DBv2 `complete()` dispatch applies `normalize_effort_for_openai_route` / + // The DBv2 `complete()` dispatch applies `normalize_effort_for_databricks_v2` / // `normalize_effort_for_anthropic_route` before calling body builders. These tests // verify the body shape that results from the already-normalized effort values — i.e., // they confirm the body builders correctly serialize the values the dispatch passes them. #[test] fn dbv2_openai_route_max_effort_clamped_to_xhigh_in_responses_body() { - // DBv2 GPT-5.5 route: max → clamped to xhigh by normalize_effort_for_openai_route + // DBv2 GPT-5.5 route: max → clamped to xhigh by normalize_effort_for_databricks_v2 // before reaching responses_body. gpt-5.5 supports xhigh so the final value is xhigh. let clamped = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.5"); + crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "gpt-5.5"); let body = responses_body( &cfg_responses(), "system", @@ -3914,7 +3968,7 @@ mod tests { #[test] fn dbv2_openai_route_max_effort_passes_through_for_gpt5_6() { let normalized = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.6-sol"); + crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "gpt-5.6-sol"); let body = responses_body( &cfg_responses(), "system", @@ -3931,10 +3985,10 @@ mod tests { #[test] fn dbv2_mlflow_route_max_effort_clamped_to_xhigh_in_openai_body() { - // DBv2 MLflow route (unknown model): max → clamped to xhigh by normalize_effort_for_openai_route. + // DBv2 MLflow route (unknown model): max → clamped to xhigh by normalize_effort_for_databricks_v2. // Unknown models pass through after the max→xhigh clamp. let clamped = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "llama-4"); + crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "llama-4"); let body = openai_body( &cfg(Provider::OpenAi), "system", @@ -3954,14 +4008,14 @@ mod tests { // Verify that supported values pass through for the respective model families. // gpt-5.5 supports none (but not minimal); gpt-5 base supports minimal (but not none). let none_normalized = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::None, "gpt-5.5"); + crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::None, "gpt-5.5"); assert_eq!( none_normalized, ThinkingEffort::None, "OpenAI normalizer must not touch none for gpt-5.5" ); let minimal_normalized = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Minimal, "gpt-5"); + crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Minimal, "gpt-5"); assert_eq!( minimal_normalized, ThinkingEffort::Minimal, @@ -3998,6 +4052,7 @@ mod tests { &[], "claude-opus-4-8", normalized, // None → omit thinking fields + "anthropic", ); assert!( body.get("thinking").is_none(), @@ -4022,6 +4077,7 @@ mod tests { // Before switch: claude-opus-4-8 with effort=max → adaptive shape, effort="max" let (thinking_before, oc_before) = crate::config::anthropic_thinking_config( + "anthropic", "claude-opus-4-8", ThinkingEffort::Max, 32_768, @@ -4032,7 +4088,7 @@ mod tests { // After switch to GPT-5.5 route: normalize max → xhigh for responses_body // (gpt-5.5 supports xhigh, so the clamp result is xhigh, not further reduced) let clamped = - crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.5"); + crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "gpt-5.5"); assert_eq!(clamped, ThinkingEffort::XHigh); let body_after = responses_body( &cfg_responses(), @@ -6811,6 +6867,7 @@ mod tests { &[], "claude-opus-4-7", None, + "anthropic", ); let messages = body["messages"].as_array().unwrap(); let assistant = messages diff --git a/crates/buzz-agent/src/model_capabilities.rs b/crates/buzz-agent/src/model_capabilities.rs new file mode 100644 index 00000000000..81f4b4e3b64 --- /dev/null +++ b/crates/buzz-agent/src/model_capabilities.rs @@ -0,0 +1,899 @@ +//! Runtime model-capability interpreter. +//! +//! `scripts/model-capabilities.json` is the single source of truth for every +//! model's six-axis capability profile (thinking mode, supported efforts, +//! default effort, Databricks v2 wire route, normalization policy, and picker +//! label). It is embedded at compile time (`include_str!`), parsed once through +//! strict `serde` (`deny_unknown_fields` + real enums), and cached in a +//! [`OnceLock`]. No codegen: both this interpreter and the TypeScript one in +//! `desktop/` read the same hand-curated manifest, and the shared normative +//! corpus (`scripts/normative-corpus.json`) is the cross-language contract that +//! guarantees they agree. +//! +//! ## Resolution algorithm (`resolve`) +//! 1. Provider canonicalization happens *inside* the resolver: trim, lowercase, +//! and apply the alias map (`openai-compat` → `openai`, +//! `databricks-v2` → `databricks_v2`). +//! 2. Provider-qualified exact-record lookup (case-insensitive on the model id). +//! 3. Boundary-aware family-rule match: strip any endpoint prefix at the first +//! family token on a non-alphanumeric boundary, then take the longest match +//! across every rule's `match_value` and `match_aliases`, breaking ties on +//! the lexicographically smallest rule id. +//! 4. Provider fallback, distinguishing a blank model id from a concrete-unknown +//! one. +//! +//! Every path yields a complete six-axis result; `registry_label` is populated +//! only on an exact-record hit. + +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; + +use crate::config::ThinkingEffort; + +/// How a model activates and controls reasoning depth on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ThinkingMode { + Adaptive, + ManualBudget, + None, + OmitFields, +} + +/// The Databricks v2 AI Gateway wire route a model is served on. `NotApplicable` +/// marks non-Databricks providers; `RouteUnknown` marks a blank Databricks id. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DatabricksV2Route { + AnthropicMessages, + MlflowChat, + NotApplicable, + OpenaiResponses, + RouteUnknown, +} + +/// Post-resolution effort normalization applied before a request is sent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum NormalizationPolicy { + None, + OpenaiClampMaxToXhigh, + OpenaiStandard, +} + +/// Whether a family rule matches its token exactly or as a boundary-aware prefix. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum MatchKind { + Exact, + Prefix, +} + +/// A family/prefix rule: matches a canonical (prefix-stripped) model id against +/// `match_value` or any `match_aliases` token for the listed providers. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FamilyRule { + id: String, + match_kind: MatchKind, + match_value: String, + #[serde(default)] + match_aliases: Vec, + providers: Vec, + thinking_mode: ThinkingMode, + supported_efforts: Vec, + default_effort: Option, + databricks_v2_wire_route: DatabricksV2Route, + normalization_policy: NormalizationPolicy, + /// Documentation only; modeled so `deny_unknown_fields` accepts the manifest. + #[serde(rename = "_comment", default)] + #[allow(dead_code)] + comment: Option, +} + +/// An authoritative six-axis snapshot for one concrete `(provider, model)` pair. +/// Exact records do *not* inherit from family rules at runtime; the doc fields +/// record the one-time provenance of each axis (see the manifest `_comment`). +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExactRecord { + provider: String, + raw_model_id: String, + registry_label: String, + thinking_mode: ThinkingMode, + supported_efforts: Vec, + default_effort: Option, + databricks_v2_wire_route: DatabricksV2Route, + normalization_policy: NormalizationPolicy, + // Documentation/provenance keys; modeled for strict parsing, not read at runtime. + #[serde(rename = "_provenance", default)] + #[allow(dead_code)] + provenance: Option, + #[serde(default)] + #[allow(dead_code)] + source: Option, + #[serde(rename = "_source", default)] + #[allow(dead_code)] + source_alt: Option, + #[serde(rename = "_reconciliation", default)] + #[allow(dead_code)] + reconciliation: Option, + #[serde(rename = "_reconciliation_note", default)] + #[allow(dead_code)] + reconciliation_note: Option, + #[serde(rename = "_reconciliation_doc", default)] + #[allow(dead_code)] + reconciliation_doc: Option, +} + +/// One provider's fallback profiles for a blank vs. a concrete-unknown model id. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FallbackPair { + blank: FallbackState, + concrete_unknown: FallbackState, +} + +/// A five-axis fallback profile (no label — fallbacks never carry one). +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FallbackState { + databricks_v2_wire_route: DatabricksV2Route, + thinking_mode: ThinkingMode, + supported_efforts: Vec, + default_effort: Option, + normalization_policy: NormalizationPolicy, +} + +/// Provider fallbacks keyed by canonical provider, with a `_default` catch-all. +/// Both states of every provider are required, so "both fallback states present" +/// is enforced structurally by the parse. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProviderFallbacks { + anthropic: FallbackPair, + openai: FallbackPair, + databricks: FallbackPair, + databricks_v2: FallbackPair, + openrouter: FallbackPair, + #[serde(rename = "_default")] + default: FallbackPair, +} + +impl ProviderFallbacks { + /// Fallback pair for a canonical provider, or `_default` for anything else. + fn get(&self, provider: &str) -> &FallbackPair { + match provider { + "anthropic" => &self.anthropic, + "openai" => &self.openai, + "databricks" => &self.databricks, + "databricks_v2" => &self.databricks_v2, + "openrouter" => &self.openrouter, + _ => &self.default, + } + } + + /// Named pairs, for validation. + fn named(&self) -> [(&str, &FallbackPair); 6] { + [ + ("anthropic", &self.anthropic), + ("openai", &self.openai), + ("databricks", &self.databricks), + ("databricks_v2", &self.databricks_v2), + ("openrouter", &self.openrouter), + ("_default", &self.default), + ] + } +} + +/// The parsed manifest. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + family_tokens: Vec, + family_rules: Vec, + databricks_v2_known_models: Vec, + exact_records: Vec, + provider_fallbacks: ProviderFallbacks, + // Root documentation keys; modeled for strict parsing, not read at runtime. + #[serde(rename = "_comment", default)] + #[allow(dead_code)] + comment: Option, + #[serde(rename = "_comment_databricks_v2_known_models", default)] + #[allow(dead_code)] + comment_known_models: Option, + #[serde(rename = "_sources", default)] + #[allow(dead_code)] + sources: std::collections::BTreeMap, +} + +/// The resolved six-axis capability profile for one `(provider, model)` query. +/// All fields borrow from the process-lifetime manifest. The field names and +/// declaration order are the corpus `expect` schema — the test-only generator +/// serializes this struct directly, so there is no second encoding of the axes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct CapabilityResult { + pub thinking_mode: ThinkingMode, + pub supported_efforts: &'static [ThinkingEffort], + pub default_effort: Option, + pub databricks_v2_wire_route: DatabricksV2Route, + pub normalization_policy: NormalizationPolicy, + pub registry_label: Option<&'static str>, +} + +const MANIFEST_JSON: &str = include_str!("../../../scripts/model-capabilities.json"); + +static MANIFEST: OnceLock = OnceLock::new(); + +/// Parse (once) and return the embedded manifest. Panics on a malformed or +/// invalid bundled manifest — a build-time data error that must never ship. +fn manifest() -> &'static Manifest { + MANIFEST.get_or_init(|| { + let parsed: Manifest = serde_json::from_str(MANIFEST_JSON) + .expect("bundled model-capabilities.json must parse"); + if let Err(e) = validate_manifest(&parsed) { + panic!("bundled model-capabilities.json failed validation: {e}"); + } + parsed + }) +} + +/// Canonicalize a provider name: trim, lowercase, apply the alias map. +fn canonical_provider(provider: &str) -> String { + let canon = provider.trim().to_ascii_lowercase(); + match canon.as_str() { + "openai-compat" => "openai".to_string(), + "databricks-v2" => "databricks_v2".to_string(), + _ => canon, + } +} + +/// Strip an endpoint-naming prefix by locating the earliest family token that +/// begins on a non-alphanumeric boundary (or at the start), returning the slice +/// from that token onward. Returns the input unchanged when no token qualifies. +fn strip_catalog_prefix<'a>(model_lower: &'a str, family_tokens: &[String]) -> &'a str { + let bytes = model_lower.as_bytes(); + let mut best: Option = None; + for tok in family_tokens { + let mut from = 0; + while let Some(rel) = model_lower[from..].find(tok.as_str()) { + let idx = from + rel; + if idx == 0 || !bytes[idx - 1].is_ascii_alphanumeric() { + best = Some(best.map_or(idx, |b| b.min(idx))); + break; + } + from = idx + 1; + } + } + match best { + Some(idx) => &model_lower[idx..], + None => model_lower, + } +} + +/// Boundary-aware prefix test: `s` equals `token`, or `s` starts with `token` +/// and the following character is a non-alphanumeric boundary. +fn prefix_matches(token: &str, s: &str) -> bool { + match s.strip_prefix(token) { + Some(rest) => rest + .chars() + .next() + .is_none_or(|c| !c.is_ascii_alphanumeric()), + None => false, + } +} + +/// Resolve the capability profile for a `(provider, raw_model_id)` pair. +pub fn resolve(provider: &str, raw_model_id: &str) -> CapabilityResult { + let m = manifest(); + let canon = canonical_provider(provider); + let blank = raw_model_id.trim().is_empty(); + + // 1. Provider-qualified exact-record lookup (case-insensitive on the id). + if !blank { + for rec in &m.exact_records { + if rec.provider == canon && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) { + return CapabilityResult { + thinking_mode: rec.thinking_mode, + supported_efforts: &rec.supported_efforts, + default_effort: rec.default_effort, + databricks_v2_wire_route: rec.databricks_v2_wire_route, + normalization_policy: rec.normalization_policy, + registry_label: Some(&rec.registry_label), + }; + } + } + } + + // 2. Boundary-aware family match: longest token wins, lexicographic tie-break. + if !blank { + let model_lower = raw_model_id.to_ascii_lowercase(); + let stripped = strip_catalog_prefix(&model_lower, &m.family_tokens); + let mut best: Option<(usize, &FamilyRule)> = None; + for rule in &m.family_rules { + if !rule.providers.iter().any(|p| p == &canon) { + continue; + } + let mut matched: Option = None; + for tok in std::iter::once(&rule.match_value).chain(rule.match_aliases.iter()) { + let ok = match rule.match_kind { + MatchKind::Exact => stripped == tok.as_str(), + MatchKind::Prefix => prefix_matches(tok, stripped), + }; + if ok { + matched = Some(matched.map_or(tok.len(), |l| l.max(tok.len()))); + } + } + if let Some(len) = matched { + let better = match best { + None => true, + Some((blen, brule)) => len > blen || (len == blen && rule.id < brule.id), + }; + if better { + best = Some((len, rule)); + } + } + } + if let Some((_, rule)) = best { + let route = if canon == "databricks_v2" { + rule.databricks_v2_wire_route + } else { + DatabricksV2Route::NotApplicable + }; + return CapabilityResult { + thinking_mode: rule.thinking_mode, + supported_efforts: &rule.supported_efforts, + default_effort: rule.default_effort, + databricks_v2_wire_route: route, + normalization_policy: rule.normalization_policy, + registry_label: None, + }; + } + } + + // 3. Provider fallback (blank vs. concrete-unknown); never carries a label. + let pair = m.provider_fallbacks.get(&canon); + let state = if blank { + &pair.blank + } else { + &pair.concrete_unknown + }; + CapabilityResult { + thinking_mode: state.thinking_mode, + supported_efforts: &state.supported_efforts, + default_effort: state.default_effort, + databricks_v2_wire_route: state.databricks_v2_wire_route, + normalization_policy: state.normalization_policy, + registry_label: None, + } +} + +/// Authoritative list of known Databricks v2 model ids, sourced from the manifest. +pub fn databricks_v2_known_models() -> &'static [String] { + &manifest().databricks_v2_known_models +} + +/// Curated display label for a Databricks endpoint id, or `None` when no exact +/// record covers it. Read-only accessor over the same `databricks_v2` exact +/// records `resolve()` consults, with the same case-insensitive id match; used +/// by discovery to curate `ModelEntry.name` (the Databricks API returns no +/// display name of its own). Scoped to `databricks_v2` records only, so it can +/// never surface a curated label for a non-Databricks provider. +pub fn databricks_registry_label(raw_model_id: &str) -> Option<&'static str> { + if raw_model_id.trim().is_empty() { + return None; + } + manifest() + .exact_records + .iter() + .find(|rec| { + rec.provider == "databricks_v2" && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) + }) + .map(|rec| rec.registry_label.as_str()) +} + +/// Semantic invariants that strict typed parsing cannot express. Structural +/// checks (required fields, enum domains, both fallback states) are already +/// guaranteed by `serde` + `deny_unknown_fields`; this owns the rest. +fn validate_manifest(m: &Manifest) -> Result<(), String> { + if m.family_tokens.is_empty() { + return Err("family_tokens must be non-empty".to_string()); + } + + let check_efforts = |ctx: &str, + efforts: &[ThinkingEffort], + default: Option| + -> Result<(), String> { + if efforts.is_empty() { + return Err(format!("{ctx}: supported_efforts must be non-empty")); + } + // Canonical enum order is None < Minimal < ... < Max; strict ascending + // enforces sorted + duplicate-free in one check. + if !efforts.windows(2).all(|w| w[0] < w[1]) { + return Err(format!( + "{ctx}: supported_efforts must be sorted in canonical order with no duplicates" + )); + } + if let Some(d) = default { + if !efforts.contains(&d) { + return Err(format!( + "{ctx}: default_effort {d:?} not in supported_efforts" + )); + } + } + Ok(()) + }; + + // Family rules: unique ids, non-empty providers, effort validity, and no + // match token (value or alias) shared across or within rules. + let mut rule_ids = std::collections::HashSet::new(); + let mut token_owner: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + for rule in &m.family_rules { + if !rule_ids.insert(rule.id.as_str()) { + return Err(format!("duplicate family rule id: {}", rule.id)); + } + if rule.providers.is_empty() { + return Err(format!("family rule {} has empty providers", rule.id)); + } + check_efforts( + &format!("family_rule {}", rule.id), + &rule.supported_efforts, + rule.default_effort, + )?; + for tok in std::iter::once(&rule.match_value).chain(rule.match_aliases.iter()) { + if let Some(prev) = token_owner.insert(tok.as_str(), rule.id.as_str()) { + return Err(format!( + "duplicate match token {tok:?} (rules {prev} and {})", + rule.id + )); + } + } + } + + // Exact records: case-insensitive uniqueness of (provider, id), non-empty + // labels, effort validity. + let mut exact_keys = std::collections::HashSet::new(); + for rec in &m.exact_records { + let key = (rec.provider.clone(), rec.raw_model_id.to_ascii_lowercase()); + if !exact_keys.insert(key) { + return Err(format!( + "duplicate exact record: {} / {}", + rec.provider, rec.raw_model_id + )); + } + if rec.registry_label.trim().is_empty() { + return Err(format!( + "exact record {} has an empty registry_label", + rec.raw_model_id + )); + } + check_efforts( + &format!("exact_record {}", rec.raw_model_id), + &rec.supported_efforts, + rec.default_effort, + )?; + } + + // Known-model ids: case-insensitive uniqueness. + let mut known = std::collections::HashSet::new(); + for id in &m.databricks_v2_known_models { + if id.trim().is_empty() { + return Err("databricks_v2_known_models contains an empty id".to_string()); + } + if !known.insert(id.to_ascii_lowercase()) { + return Err(format!("duplicate databricks_v2_known_models id: {id}")); + } + } + + // Provider fallbacks: effort validity for both states of every provider. + for (name, pair) in m.provider_fallbacks.named() { + check_efforts( + &format!("fallback {name}/blank"), + &pair.blank.supported_efforts, + pair.blank.default_effort, + )?; + check_efforts( + &format!("fallback {name}/concrete_unknown"), + &pair.concrete_unknown.supported_efforts, + pair.concrete_unknown.default_effort, + )?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One entry of the generator's *inputs-only* table. It encodes **which + /// questions to ask** — section headers and `(provider, raw_model_id)` + /// query pairs plus a human note — and never any expected answer. Every + /// answer is computed by the production [`resolve`] at generation time, so + /// the manifest stays the single place capability behavior is encoded. + enum Q { + Section { + group: &'static str, + note: Option<&'static str>, + }, + Vector { + id: &'static str, + provider: &'static str, + raw_model_id: &'static str, + note: Option<&'static str>, + }, + } + + /// The inputs-only question set (section headers interleaved with query + /// vectors, in file order). Answers live only in the manifest; this table + /// says which questions to ask. Adding, removing, or reordering a `Vector` + /// here changes the generated corpus — run `just regen-model-corpus`. + const INPUTS: &[Q] = &[ + Q::Section { group: "Anthropic curated family-rule model names", note: None }, + Q::Vector { id: "anthropic-claude-3-family", provider: "anthropic", raw_model_id: "claude-3-7-sonnet-20250219", note: None }, + Q::Vector { id: "anthropic-claude-opus-4-5", provider: "anthropic", raw_model_id: "claude-opus-4-5", note: None }, + Q::Vector { id: "anthropic-claude-opus-4-7", provider: "anthropic", raw_model_id: "claude-opus-4-7", note: None }, + Q::Vector { id: "anthropic-claude-opus-4-8", provider: "anthropic", raw_model_id: "claude-opus-4-8", note: None }, + Q::Vector { id: "anthropic-claude-sonnet-5", provider: "anthropic", raw_model_id: "claude-sonnet-5-20260101", note: None }, + Q::Vector { id: "anthropic-claude-fable-5", provider: "anthropic", raw_model_id: "claude-fable-5", note: None }, + Q::Vector { id: "anthropic-claude-mythos-5", provider: "anthropic", raw_model_id: "claude-mythos-5", note: None }, + Q::Vector { id: "anthropic-claude-opus-4-6", provider: "anthropic", raw_model_id: "claude-opus-4-6", note: None }, + Q::Vector { id: "anthropic-claude-sonnet-4-6", provider: "anthropic", raw_model_id: "claude-sonnet-4-6", note: None }, + Q::Vector { id: "anthropic-claude-mythos-preview", provider: "anthropic", raw_model_id: "claude-mythos-preview", note: None }, + Q::Section { group: "Anthropic blank and concrete-unknown inputs", note: None }, + Q::Vector { id: "anthropic-unknown-blank", provider: "anthropic", raw_model_id: "", note: None }, + Q::Vector { id: "anthropic-unknown-concrete", provider: "anthropic", raw_model_id: "claude-ultra-9000", note: None }, + Q::Section { group: "OpenAI curated family-rule model names", note: None }, + Q::Vector { id: "openai-gpt5-pro", provider: "openai", raw_model_id: "gpt-5-pro", note: None }, + Q::Vector { id: "openai-gpt5.6", provider: "openai", raw_model_id: "gpt-5.6", note: None }, + Q::Vector { id: "openai-gpt5-6-dashed", provider: "openai", raw_model_id: "gpt-5-6", note: None }, + Q::Vector { id: "openai-gpt5.5", provider: "openai", raw_model_id: "gpt-5.5", note: None }, + Q::Vector { id: "openai-gpt5.4", provider: "openai", raw_model_id: "gpt-5.4", note: None }, + Q::Vector { id: "openai-gpt5.1", provider: "openai", raw_model_id: "gpt-5.1", note: None }, + Q::Vector { id: "openai-gpt5-base", provider: "openai", raw_model_id: "gpt-5", note: None }, + Q::Section { group: "OpenAI gpt-5 boundary-matching probes (ported from config.rs tests)", note: None }, + Q::Vector { id: "openai-gpt5-1106-date-suffix-probe", provider: "openai", raw_model_id: "gpt-5-1106", note: Some("Probes a 4-digit date-shaped suffix after the gpt-5 stem.") }, + Q::Vector { id: "openai-gpt5-4o-alpha-suffix-probe", provider: "openai", raw_model_id: "gpt-5-4o", note: Some("Probes a leading-digit-then-letter suffix ('4o') after the gpt-5 stem.") }, + Q::Vector { id: "openai-gpt5-pro-precedence-probe", provider: "openai", raw_model_id: "gpt-5-pro", note: Some("Probes precedence between the gpt-5-pro rule and the gpt-5 base stem.") }, + Q::Vector { id: "openai-gpt5-10-multi-digit-probe", provider: "openai", raw_model_id: "gpt-5-10", note: Some("Probes a two-digit minor-version suffix after the gpt-5 stem.") }, + Q::Vector { id: "openai-gpt5-date-suffix-probe", provider: "openai", raw_model_id: "gpt-5-20260101", note: Some("Probes an 8-digit date suffix after the gpt-5 stem.") }, + Q::Section { group: "DatabricksV2 segment/prefix routing probes (ported from llm.rs tests)", note: None }, + Q::Vector { id: "dbv2-gpt5-5-probe", provider: "databricks_v2", raw_model_id: "gpt-5.5", note: None }, + Q::Vector { id: "dbv2-claude-opus-4-7-probe", provider: "databricks_v2", raw_model_id: "claude-opus-4-7", note: None }, + Q::Vector { id: "dbv2-databricks-prefix-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-4-7", note: Some("Probes stripping of the databricks- catalog prefix.") }, + Q::Vector { id: "dbv2-goose-claude-prefix-probe", provider: "databricks_v2", raw_model_id: "goose-claude-fable-5", note: Some("Probes stripping of the goose- catalog prefix.") }, + Q::Vector { id: "dbv2-team-prefix-probe", provider: "databricks_v2", raw_model_id: "team-x-claude-opus-4-7", note: Some("Probes stripping of a team-x- catalog prefix.") }, + Q::Vector { id: "dbv2-consolidated-llama-substring-probe", provider: "databricks_v2", raw_model_id: "consolidated-llama", note: Some("Probes a name where a code word ('sol') appears only as a substring, not a boundary-aligned segment.") }, + Q::Vector { id: "dbv2-terraform-coder-substring-probe", provider: "databricks_v2", raw_model_id: "terraform-coder", note: Some("Probes a name where a code word ('terra') is only a segment prefix, not a full segment.") }, + Q::Vector { id: "dbv2-corpus-reranker-substring-probe", provider: "databricks_v2", raw_model_id: "corpus-reranker", note: Some("Probes a name where 'opus' appears only as a substring of a segment.") }, + Q::Vector { id: "dbv2-octopus-model-substring-probe", provider: "databricks_v2", raw_model_id: "octopus-model", note: Some("Probes a name where 'opus' appears only as a substring of a segment.") }, + Q::Vector { id: "dbv2-goose-opus-5-prefix-probe", provider: "databricks_v2", raw_model_id: "goose-opus-5", note: Some("Probes a goose- prefix over a bare code-name segment with no leading claude.") }, + Q::Section { group: "Resolver-contract probes (plan v4 §Resolver contract)", note: None }, + Q::Vector { id: "resolver-exact-raw-id-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes a raw id that has an exact record.") }, + Q::Vector { id: "resolver-prefixed-alias-probe", provider: "databricks_v2", raw_model_id: "team-x-databricks-gpt-5-4-mini", note: Some("Probes a prefixed alias of an exact-record id (raw exact key differs).") }, + Q::Vector { id: "resolver-cross-provider-probe", provider: "openai", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes the same raw id under a different provider (exact records are provider-scoped).") }, + Q::Vector { id: "resolver-exact-record-with-family-route-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-sol", note: Some("Exact-vs-family route-axis probe (raw exact key with a covering family rule).") }, + Q::Vector { id: "dbv2-gpt5-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-5", note: Some("Exact-vs-family effort-axis probe (exact record overlapping a family rule).") }, + Q::Section { group: "Blank and concrete-unknown inputs per provider", note: None }, + Q::Vector { id: "dbv2-blank-probe", provider: "databricks_v2", raw_model_id: "", note: Some("Probes a blank databricks_v2 model id.") }, + Q::Vector { id: "dbv2-concrete-unknown-probe", provider: "databricks_v2", raw_model_id: "some-unknown-model-xyz", note: Some("Probes a concrete, uncatalogued databricks_v2 model id.") }, + Q::Vector { id: "openai-blank-probe", provider: "openai", raw_model_id: "", note: Some("Probes a blank openai model id.") }, + Q::Vector { id: "openai-concrete-unknown-probe", provider: "openai", raw_model_id: "gpt-4o", note: Some("Probes a concrete openai model id in no verified family.") }, + Q::Vector { id: "anthropic-blank-probe", provider: "anthropic", raw_model_id: "", note: Some("Probes a blank anthropic model id.") }, + Q::Vector { id: "anthropic-concrete-unknown-probe", provider: "anthropic", raw_model_id: "claude-ultra-9000", note: Some("Probes a concrete, uncatalogued anthropic model id.") }, + Q::Section { group: "Legacy Databricks provider inputs", note: None }, + Q::Vector { id: "databricks-gpt5-pro-probe", provider: "databricks", raw_model_id: "databricks-gpt-5-pro", note: Some("Probes the legacy databricks provider with a GPT-5 Pro id.") }, + Q::Vector { id: "databricks-gpt5-6-probe", provider: "databricks", raw_model_id: "databricks-gpt-5.6", note: Some("Probes the legacy databricks provider with a GPT-5.6 id.") }, + Q::Vector { id: "databricks-gpt5-1-probe", provider: "databricks", raw_model_id: "databricks-gpt-5.1", note: Some("Probes the legacy databricks provider with a GPT-5.1 id.") }, + Q::Section { group: "openai-compat alias canonicalization probes", note: Some("Probes whether openai-compat is canonicalized to openai before resolving; both interpreters must agree.") }, + Q::Vector { id: "openai-compat-gpt-5-pro-probe", provider: "openai-compat", raw_model_id: "gpt-5-pro", note: None }, + Q::Vector { id: "openai-compat-gpt-5-5-probe", provider: "openai-compat", raw_model_id: "gpt-5.5", note: None }, + Q::Vector { id: "openai-compat-blank-probe", provider: "openai-compat", raw_model_id: "", note: Some("Probes openai-compat canonicalization with a blank model id.") }, + Q::Section { group: "gpt-5 short-version-suffix boundary probes (Rust/TS divergence window)", note: Some("Probes the 1-2 digit version-suffix window where the Rust guard and the TS regex historically diverged.") }, + Q::Vector { id: "openai-gpt5-10-preview-probe", provider: "openai", raw_model_id: "gpt-5-10-preview", note: None }, + Q::Vector { id: "openai-gpt5-2-mini-probe", provider: "openai", raw_model_id: "gpt-5-2-mini", note: None }, + Q::Vector { id: "openai-gpt5-9-dot-1-probe", provider: "openai", raw_model_id: "gpt-5-9.1", note: None }, + Q::Vector { id: "dbv2-gpt5-10-multi-axis-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-10", note: Some("Probes a databricks_v2 gpt-5- id, exercising both the effort axes and the wire route.") }, + Q::Vector { id: "dbv2-gpt-5-2-exact-vs-base-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-2", note: Some("Exact-vs-base-stem probe: an exact record coexisting with the gpt-5 base stem rule.") }, + Q::Vector { id: "openai-customgpt-5-5-nonboundary-probe", provider: "openai", raw_model_id: "customgpt-5-5-endpoint", note: Some("Probes a name whose gpt- token is not boundary-aligned (preceded by 'm' in customgpt).") }, + Q::Section { group: "DBv2 gpt-segment boundary probes", note: Some("Probes whether 'gpt' is treated as a full segment rather than a segment prefix.") }, + Q::Vector { id: "dbv2-gptoss-segment-probe", provider: "databricks_v2", raw_model_id: "gptoss-model", note: Some("Probes a segment ('gptoss') that starts with but is not exactly 'gpt'/'gpt5'.") }, + Q::Vector { id: "dbv2-gptj-6b-segment-probe", provider: "databricks_v2", raw_model_id: "gptj-6b", note: Some("Probes a segment ('gptj') that is not exactly 'gpt'/'gpt5'.") }, + Q::Vector { id: "dbv2-customgpt-nonboundary-probe", provider: "databricks_v2", raw_model_id: "customgpt-5-5-endpoint", note: Some("Probes a name whose gpt- token is not boundary-aligned (preceded by 'm' in customgpt).") }, + Q::Vector { id: "dbv2-gpt-neox-version-segment-probe", provider: "databricks_v2", raw_model_id: "gpt-neox-20b", note: Some("Probes a gpt- name whose next segment ('neox') is non-numeric.") }, + Q::Vector { id: "dbv2-gpt5-custom-segment-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt5-custom", note: Some("Probes a 'gpt5' segment inside a databricks- prefixed name.") }, + Q::Vector { id: "dbv2-gpt-opus-5-dual-marker-probe", provider: "databricks_v2", raw_model_id: "gpt-opus-5", note: Some("Probes a name carrying both a gpt marker and a claude code word.") }, + Q::Section { group: "Additional coverage probes", note: None }, + Q::Vector { id: "anthropic-opus-5-prefix-probe", provider: "anthropic", raw_model_id: "claude-opus-5-20270101", note: Some("Probes the claude-opus-5 prefix rule.") }, + Q::Vector { id: "dbv2-gpt-5-6-sol-normalization-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-sol", note: Some("Probes the sol exact record's normalization and effort axes.") }, + Q::Vector { id: "dbv2-gpt-5-6-luna-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-luna", note: Some("Probes the luna exact record against its family rule.") }, + Q::Vector { id: "dbv2-gpt-5-6-terra-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-terra", note: Some("Probes the terra exact record against its family rule.") }, + Q::Vector { id: "dbv2-gpt-5-4-nano-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-4-nano", note: Some("Probes the gpt-5-4-nano exact record and its label.") }, + Q::Vector { id: "openrouter-concrete-unknown-probe", provider: "openrouter", raw_model_id: "some-model-xyz", note: Some("Probes an uncatalogued openrouter model id.") }, + Q::Vector { id: "openai-gpt5-pro-uppercase-provider-probe", provider: "OpenAI", raw_model_id: "gpt-5-pro", note: Some("Probes an uppercased provider string ('OpenAI').") }, + Q::Vector { id: "dbv2-uppercase-model-probe", provider: "databricks_v2", raw_model_id: "DATABRICKS-GPT-5-4-NANO", note: Some("Probes an uppercased raw model id against a lowercase exact record.") }, + Q::Section { group: "Prototype-key provider probes", note: Some("Probes provider strings that collide with Object prototype keys.") }, + Q::Vector { id: "prototype-key-constructor-blank-probe", provider: "constructor", raw_model_id: "", note: None }, + Q::Vector { id: "prototype-key-constructor-some-model-probe", provider: "constructor", raw_model_id: "some-model", note: None }, + Q::Vector { id: "prototype-key-proto__-blank-probe", provider: "__proto__", raw_model_id: "", note: None }, + Q::Vector { id: "prototype-key-proto__-some-model-probe", provider: "__proto__", raw_model_id: "some-model", note: None }, + Q::Section { group: "Non-boundary gpt- prefix probes", note: Some("Probes names whose gpt- token is not boundary-aligned (preceded by an alphanumeric).") }, + Q::Vector { id: "openai-sgpt-5-5-nonboundary-probe", provider: "openai", raw_model_id: "sgpt-5-5", note: None }, + Q::Vector { id: "dbv2-sgpt-5-5-nonboundary-probe", provider: "databricks_v2", raw_model_id: "sgpt-5-5", note: None }, + Q::Vector { id: "openai-mygpt-5-nonboundary-probe", provider: "openai", raw_model_id: "mygpt-5", note: None }, + Q::Vector { id: "dbv2-mygpt-5-nonboundary-probe", provider: "databricks_v2", raw_model_id: "mygpt-5", note: None }, + Q::Vector { id: "dbv2-gpt-5-mini-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-mini", note: Some("Probes the gpt-5-mini exact record and its label.") }, + Q::Vector { id: "dbv2-gpt-5-nano-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-nano", note: Some("Probes the gpt-5-nano exact record and its label.") }, + Q::Vector { id: "dbv2-claude-opus-5-custom-family-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-5-custom", note: Some("Probes a family-matched name with no exact record and its label axis.") }, + Q::Vector { id: "dbv2-gpt-doubled-separator-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt--5", note: Some("Probes a doubled separator between gpt and its version.") }, + Q::Section { group: "gpt-5 prefix collision probes (longest-prefix + boundary)", note: None }, + Q::Vector { id: "collision-gpt-5-base-probe", provider: "openai", raw_model_id: "gpt-5", note: Some("Probes the base gpt-5 stem alone.") }, + Q::Vector { id: "collision-gpt-5-pro-probe", provider: "openai", raw_model_id: "gpt-5-pro", note: Some("Probes gpt-5-pro against the shorter gpt-5 stem.") }, + Q::Vector { id: "collision-gpt-5-10-probe", provider: "openai", raw_model_id: "gpt-5-10", note: Some("Probes a two-digit minor version against the gpt-5 stem.") }, + Q::Vector { id: "collision-gpt-5-6-probe", provider: "openai", raw_model_id: "gpt-5.6", note: Some("Probes a dotted minor version against the gpt-5 stem.") }, + Q::Vector { id: "collision-gpt-5-1-probe", provider: "openai", raw_model_id: "gpt-5.1", note: Some("Probes the gpt-5.1 prefix.") }, + Q::Section { group: "Uncurated DBv2 token probes", note: None }, + Q::Vector { id: "uncurated-dbv2-gpt-6-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-6", note: Some("Probes a non-5 gpt version with no exact record or prefix rule.") }, + Q::Vector { id: "uncurated-dbv2-gpt-4o-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-4o", note: Some("Probes an uncatalogued gpt-4o databricks_v2 id.") }, + Q::Vector { id: "uncurated-dbv2-opus-5-bare-probe", provider: "databricks_v2", raw_model_id: "opus-5", note: Some("Probes a bare Claude code-name segment with no leading claude.") }, + Q::Vector { id: "uncurated-dbv2-sol-bare-probe", provider: "databricks_v2", raw_model_id: "sol", note: Some("Probes a bare OpenAI code name.") }, + Q::Vector { id: "uncurated-dbv2-claude-prefix-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-experimental", note: Some("Probes an uncurated databricks-claude-* name.") }, + Q::Section { group: "Negative-match probes (no family rule expected to bind)", note: None }, + Q::Vector { id: "neg-gptoss-openai-probe", provider: "openai", raw_model_id: "gptoss", note: Some("Probes a name with no gpt- boundary token.") }, + Q::Vector { id: "neg-gptj-6b-openai-probe", provider: "openai", raw_model_id: "gptj-6b", note: Some("Probes 'gptj', which is not a gpt- token.") }, + Q::Vector { id: "neg-consolidated-llama-dbv2-probe", provider: "databricks_v2", raw_model_id: "consolidated-llama", note: Some("Probes a name where 'sol' is a substring, not a segment.") }, + Q::Vector { id: "neg-terraform-coder-dbv2-probe", provider: "databricks_v2", raw_model_id: "terraform-coder", note: Some("Probes a name where 'terra' is a substring, not a segment.") }, + Q::Vector { id: "neg-octopus-model-dbv2-probe", provider: "databricks_v2", raw_model_id: "octopus-model", note: Some("Probes a name where 'opus' is a substring, not a leading claude prefix.") }, + Q::Section { group: "Exact+prefix matcher boundary probes", note: None }, + Q::Vector { id: "boundary-embedded-token-openai-probe", provider: "openai", raw_model_id: "gpt-4-gpt-5-pro", note: Some("Probes a gpt-5-pro token embedded mid-name rather than at the start.") }, + Q::Vector { id: "boundary-dot-suffix-openai-probe", provider: "openai", raw_model_id: "gpt-5.6.x", note: Some("Probes a trailing dot-delimited segment after gpt-5.6.") }, + Q::Vector { id: "boundary-claude-3-digit-run-anthropic-probe", provider: "anthropic", raw_model_id: "claude-35", note: Some("Probes whether the claude-3 prefix binds a longer digit run ('35').") }, + Q::Vector { id: "boundary-claude-opus-4-70-anthropic-probe", provider: "anthropic", raw_model_id: "claude-opus-4-70", note: Some("Probes whether the claude-opus-4-7 prefix binds a longer digit run ('70').") }, + Q::Vector { id: "boundary-gpt-5-1234-openai-probe", provider: "openai", raw_model_id: "gpt-5-1234", note: Some("Probes a 4-digit run after the gpt-5 stem.") }, + ]; + + /// A section marker in the generated corpus (`_group` + optional `_note`). + #[derive(Serialize)] + struct SectionOut { + #[serde(rename = "_group")] + group: &'static str, + #[serde(rename = "_note", skip_serializing_if = "Option::is_none")] + note: Option<&'static str>, + } + + /// One executable vector: the query, an optional note, and the resolver's + /// snapshotted answer. `expect` is a [`CapabilityResult`] serialized + /// directly — the axis names/order and the enum spellings come from the + /// production types, so nothing about the answer is encoded a second time. + #[derive(Serialize)] + struct VectorOut { + id: &'static str, + provider: &'static str, + raw_model_id: &'static str, + #[serde(rename = "_note", skip_serializing_if = "Option::is_none")] + note: Option<&'static str>, + expect: CapabilityResult, + } + + /// A heterogeneous corpus entry. `untagged` writes the inner object with no + /// discriminator, yielding the one flat array the harnesses replay. + #[derive(Serialize)] + #[serde(untagged)] + enum CorpusOut { + Section(SectionOut), + Vector(VectorOut), + } + + const CORPUS_JSON: &str = include_str!("../../../scripts/normative-corpus.json"); + + /// Render the corpus from [`INPUTS`] by running the production [`resolve`] + /// over every query. Deterministic: fixed input order, struct-declaration + /// key order, `serde_json` pretty (2-space) formatting, trailing newline. + /// This is the single writer used by both the drift gate and the regen + /// recipe, so "what the gate checks" and "what regen writes" cannot drift. + fn generate_corpus_json() -> String { + let entries: Vec = INPUTS + .iter() + .map(|q| match *q { + Q::Section { group, note } => CorpusOut::Section(SectionOut { group, note }), + Q::Vector { + id, + provider, + raw_model_id, + note, + } => CorpusOut::Vector(VectorOut { + id, + provider, + raw_model_id, + note, + expect: resolve(provider, raw_model_id), + }), + }) + .collect(); + let mut json = serde_json::to_string_pretty(&entries) + .expect("corpus entries serialize as pretty JSON"); + json.push('\n'); + json + } + + /// Absolute path of the committed corpus, from the crate root at compile + /// time — the same file [`CORPUS_JSON`] embeds, so the regen recipe writes + /// exactly what the drift gate reads. + fn corpus_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/normative-corpus.json") + } + + #[test] + fn bundled_manifest_parses_and_validates() { + // Exercises the include_str! + strict serde + validate_manifest chain. + let _ = manifest(); + } + + #[test] + fn corpus_matches_generated_snapshot() { + // Drift gate: the committed corpus must be byte-identical to what the + // production resolver generates right now. A byte match proves every + // `expect` in the file is the resolver's current answer — the same + // cross-language contract the old hand-maintained corpus enforced, + // now impossible to hand-edit out of sync. `just regen-model-corpus` + // rewrites the file from this exact generator. + assert_eq!( + CORPUS_JSON, + generate_corpus_json(), + "scripts/normative-corpus.json is out of date — run `just regen-model-corpus` and commit the result" + ); + } + + #[test] + fn corpus_has_exactly_103_executable_vectors() { + // Locks the vector count so a silent INPUTS edit can't quietly drop + // coverage; must equal the gate in the TS harness + // (modelCapabilitiesCorpus.test.mjs). + let vectors = INPUTS + .iter() + .filter(|q| matches!(q, Q::Vector { .. })) + .count(); + assert_eq!( + vectors, 103, + "corpus executable-vector count changed; update this gate deliberately" + ); + } + + /// Rewrite `scripts/normative-corpus.json` from the production resolver. + /// `#[ignore]` so the ordinary test run only *checks* the committed bytes + /// (via `corpus_matches_generated_snapshot`); this is the writer half, + /// invoked by `just regen-model-corpus`. + #[test] + #[ignore = "writer, not a check — run via `just regen-model-corpus`"] + fn regen_corpus_file() { + std::fs::write(corpus_path(), generate_corpus_json()) + .expect("write scripts/normative-corpus.json"); + } + + // --- Migrated relational/invariant tests (see 42-test inventory) --- + // These assert cross-input properties a single corpus vector cannot express. + + #[test] + fn test_gpt5_numeric_date_suffix_matches_base_not_version() { + // A 4-digit date-like suffix on a non-boundary must fall to the gpt-5 base, + // never to the gpt-5.1 version rule. + let base = resolve("openai", "gpt-5"); + for id in ["gpt-5-1106", "gpt-5-20260101"] { + assert_eq!( + resolve("openai", id).supported_efforts, + base.supported_efforts, + "{id} must match gpt-5 base efforts" + ); + } + } + + #[test] + fn test_gpt5_lettered_suffix_matches_base_not_gpt5_4() { + // `gpt-5-4o` has an alnum char after `gpt-5-4`, so the gpt-5.4 rule must + // not match; it falls to the base rule. + let base = resolve("openai", "gpt-5"); + let gpt5_4 = resolve("openai", "gpt-5.4"); + let got = resolve("openai", "gpt-5-4o"); + assert_eq!(got.supported_efforts, base.supported_efforts); + assert_ne!(got.supported_efforts, gpt5_4.supported_efforts); + } + + #[test] + fn test_gpt5_pro_wins_over_base_by_longest_prefix() { + // `gpt-5-pro` matches both the base (`gpt-5`) and the pro rule; longest + // prefix must select pro (high-only). + let pro = resolve("openai", "gpt-5-pro"); + let base = resolve("openai", "gpt-5"); + assert_eq!(pro.supported_efforts, &[ThinkingEffort::High]); + assert_ne!(pro.supported_efforts, base.supported_efforts); + } + + #[test] + fn test_every_resolve_yields_a_complete_result() { + // Complete-result invariant: supported_efforts is never empty on any path. + let inputs = [ + ("anthropic", "claude-opus-4-7"), + ("anthropic", ""), + ("anthropic", "claude-ultra-9000"), + ("openai", "gpt-5"), + ("openai", ""), + ("openai", "gpt-4o"), + ("databricks_v2", "databricks-gpt-5-4-mini"), + ("databricks_v2", ""), + ("databricks_v2", "some-unknown-xyz"), + ("databricks", "databricks-gpt-5-pro"), + ("openrouter", "whatever"), + ("openai-compat", "gpt-5.5"), + ("__proto__", ""), + ("constructor", "some-model"), + ("", ""), + ("totally-unknown", "totally-unknown"), + ]; + for (provider, model) in inputs { + let got = resolve(provider, model); + assert!( + !got.supported_efforts.is_empty(), + "resolve({provider:?}, {model:?}) returned empty supported_efforts" + ); + } + } + + // --- New direct-resolver tests (contract 5) --- + + #[test] + fn test_whitespace_only_model_id_uses_blank_fallback() { + // A whitespace-only id trims to blank and takes the blank fallback, which + // differs from the concrete-unknown fallback for databricks_v2 (route). + let ws = resolve("databricks_v2", " "); + let blank = resolve("databricks_v2", ""); + assert_eq!(ws, blank); + assert_eq!(ws.databricks_v2_wire_route, DatabricksV2Route::RouteUnknown); + let concrete = resolve("databricks_v2", "some-unknown-xyz"); + assert_eq!( + concrete.databricks_v2_wire_route, + DatabricksV2Route::MlflowChat + ); + } + + #[test] + fn test_prefix_tie_break_is_lexicographic_on_rule_id() { + // gpt-5.1 matches the gpt-5.1 rule's exact value (len 7) over the base + // prefix (len 5); the longest-match + tie-break path is deterministic. + let a = resolve("openai", "gpt-5.1"); + let b = resolve("openai", "gpt-5.1"); + assert_eq!(a, b); + assert_eq!(a.default_effort, Some(ThinkingEffort::None)); + } + + #[test] + fn test_exact_record_beats_family_prefix() { + // databricks-gpt-5-4-mini has an exact record (label present); the family + // prefix would otherwise apply and carry no label. + let got = resolve("databricks_v2", "databricks-gpt-5-4-mini"); + assert_eq!(got.registry_label, Some("GPT-5.4 mini")); + } + + #[test] + fn test_known_models_accessor_reads_manifest() { + let known = databricks_v2_known_models(); + assert!(known.iter().any(|m| m == "databricks-gpt-5-5")); + assert!(known.iter().any(|m| m == "databricks-claude-opus-4-7")); + } + + #[test] + fn test_databricks_registry_label_lookup() { + // Known id → curated label; case-insensitive on the id, matching resolve(). + assert_eq!( + databricks_registry_label("databricks-gpt-5-5"), + Some("GPT-5.5") + ); + assert_eq!( + databricks_registry_label("DATABRICKS-GPT-5-5"), + Some("GPT-5.5") + ); + // Unknown id and blank input → no label. + assert_eq!(databricks_registry_label("custom-unlisted-endpoint"), None); + assert_eq!(databricks_registry_label(" "), None); + } +} diff --git a/desktop/src/features-manifest.d.ts b/desktop/src/features-manifest.d.ts index c1f6172ada9..bcb65d0cffa 100644 --- a/desktop/src/features-manifest.d.ts +++ b/desktop/src/features-manifest.d.ts @@ -2,3 +2,8 @@ declare module "@features-manifest" { const manifest: import("@/shared/features/types").FeaturesManifest; export default manifest; } + +declare module "@model-capabilities-manifest" { + const manifest: unknown; + export default manifest; +} diff --git a/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs index 696a055d208..11d5f74d8f2 100644 --- a/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs +++ b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs @@ -56,3 +56,140 @@ test("resolveAgentCardModelLabel — non-inherited agent with a blank resolved m }); assert.equal(label, "Default model (claude-sonnet)"); }); + +// Databricks registry integration +import { formatAgentModelLabel } from "./formatAgentModelLabel.ts"; + +test("formatAgentModelLabel — known Databricks managed ID returns curated name", () => { + assert.equal(formatAgentModelLabel("databricks-gpt-5-5"), "GPT-5.5"); + assert.equal( + formatAgentModelLabel("databricks-claude-opus-4-7"), + "Claude Opus 4.7", + ); +}); + +test("formatAgentModelLabel — unknown custom Databricks ID returns raw ID unchanged", () => { + assert.equal( + formatAgentModelLabel("databricks-team-2025-01"), + "databricks-team-2025-01", + ); +}); + +test("formatAgentModelLabel — non-Databricks ID returns raw ID unchanged", () => { + assert.equal(formatAgentModelLabel("claude-sonnet-4-7"), "claude-sonnet-4-7"); + assert.equal(formatAgentModelLabel("gpt-4o"), "gpt-4o"); +}); + +test("formatAgentModelLabel — null or empty returns Auto", () => { + assert.equal(formatAgentModelLabel(null), "Auto"); + assert.equal(formatAgentModelLabel(""), "Auto"); + assert.equal(formatAgentModelLabel(" "), "Auto"); +}); + +import { resolveModelLabel } from "./formatAgentModelLabel.ts"; + +test("resolveModelLabel — echoed id name falls through to the registry (real discovery shape)", () => { + // buzz-agent's Databricks discovery emits {id, name: id}; the echoed name + // carries no display info, so the registry tier must curate the label. + assert.equal( + resolveModelLabel( + "databricks-gpt-5-5", + "databricks-gpt-5-5", + "databricks_v2", + ), + "GPT-5.5", + ); +}); + +test("resolveModelLabel — echoed id name for an unknown id stays raw", () => { + assert.equal( + resolveModelLabel( + "databricks-team-2025-01", + "databricks-team-2025-01", + "databricks_v2", + ), + "databricks-team-2025-01", + ); +}); + +test("resolveModelLabel — a discovered name distinct from the id still wins tier 1", () => { + // The "(default catalog)" suffixed name (and any genuinely distinct name) is + // authoritative and must not be discarded by the echo-equality check. + assert.equal( + resolveModelLabel( + "databricks-gpt-5-5", + "GPT-5.5 (default catalog)", + "databricks_v2", + ), + "GPT-5.5 (default catalog)", + ); +}); + +test("resolveAgentCardModelLabel — known Databricks defaultModel with databricks_v2 provider renders curated name in default label", () => { + const label = resolveAgentCardModelLabel({ + agent: undefined, + personaModel: null, + provider: "databricks_v2", + defaultModel: "databricks-gpt-5-5", + }); + assert.equal(label, "Default model (GPT-5.5)"); +}); + +test("resolveAgentCardModelLabel — unknown custom Databricks defaultModel renders raw ID in default label", () => { + const label = resolveAgentCardModelLabel({ + agent: undefined, + personaModel: null, + defaultModel: "databricks-team-2025-01", + }); + assert.equal(label, "Default model (databricks-team-2025-01)"); +}); + +test("resolveAgentCardModelLabel — known Databricks agent model renders curated name", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "definition", model: "databricks-gpt-oss-120b" }, + personaModel: null, + defaultModel: "something-else", + }); + assert.equal(label, "GPT OSS 120B"); +}); + +// P2 regression: provider-scoped default label — Databricks ID under openai/anthropic must render raw +test("resolveAgentCardModelLabel — openai agent inheriting a Databricks-named default renders raw ID", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "global", model: null, provider: "openai" }, + personaModel: null, + provider: "openai", + defaultModel: "databricks-gpt-5-5", + }); + assert.equal(label, "Default model (databricks-gpt-5-5)"); +}); + +test("resolveAgentCardModelLabel — anthropic agent inheriting a Databricks-named default renders raw ID", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "global", model: null, provider: "anthropic" }, + personaModel: null, + provider: "anthropic", + defaultModel: "databricks-gpt-5-5", + }); + assert.equal(label, "Default model (databricks-gpt-5-5)"); +}); + +test("resolveAgentCardModelLabel — databricks_v2 agent inheriting a Databricks-named default renders curated name", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "global", model: null, provider: "databricks_v2" }, + personaModel: null, + provider: "databricks_v2", + defaultModel: "databricks-gpt-5-5", + }); + assert.equal(label, "Default model (GPT-5.5)"); +}); + +test("resolveAgentCardModelLabel — unspawned openai persona with Databricks-named default renders raw ID", () => { + const label = resolveAgentCardModelLabel({ + agent: undefined, + personaModel: null, + provider: "openai", + defaultModel: "databricks-gpt-5-5", + }); + assert.equal(label, "Default model (databricks-gpt-5-5)"); +}); diff --git a/desktop/src/features/agents/lib/agentCardModelLabel.ts b/desktop/src/features/agents/lib/agentCardModelLabel.ts index 4ec06f10c57..50b2d3e5bff 100644 --- a/desktop/src/features/agents/lib/agentCardModelLabel.ts +++ b/desktop/src/features/agents/lib/agentCardModelLabel.ts @@ -1,4 +1,7 @@ -import { formatAgentModelLabel } from "./formatAgentModelLabel"; +import { + formatAgentModelLabel, + resolveModelLabel, +} from "./formatAgentModelLabel"; import type { ManagedAgent } from "@/shared/api/types"; /** @@ -19,26 +22,33 @@ import type { ManagedAgent } from "@/shared/api/types"; * than falling through to "inherited" for lack of an instance. */ export function resolveAgentCardModelLabel(input: { - agent: Pick | undefined; + agent: Pick | undefined; personaModel: string | null | undefined; + /** Inference provider for the persona/agent — threads provider-qualified label lookup. */ + provider?: string | null | undefined; defaultModel: string; }): string { if (input.agent) { const isInherited = !input.agent.modelSource || input.agent.modelSource === "global"; if (isInherited) { - return formatDefaultModelLabel(input.defaultModel); + return formatDefaultModelLabel(input.defaultModel, input.agent.provider); } return input.agent.model?.trim() - ? formatAgentModelLabel(input.agent.model) - : formatDefaultModelLabel(input.defaultModel); + ? formatAgentModelLabel(input.agent.model, input.agent.provider) + : formatDefaultModelLabel(input.defaultModel, input.agent.provider); } return input.personaModel?.trim() - ? formatAgentModelLabel(input.personaModel) - : formatDefaultModelLabel(input.defaultModel); + ? formatAgentModelLabel(input.personaModel, input.provider) + : formatDefaultModelLabel(input.defaultModel, input.provider); } -export function formatDefaultModelLabel(defaultModel: string) { +export function formatDefaultModelLabel( + defaultModel: string, + provider?: string | null | undefined, +) { const model = defaultModel.trim(); - return model ? `Default model (${model})` : "Default model"; + return model + ? `Default model (${resolveModelLabel(model, undefined, provider)})` + : "Default model"; } diff --git a/desktop/src/features/agents/lib/formatAgentModelLabel.ts b/desktop/src/features/agents/lib/formatAgentModelLabel.ts index 6c32d53937a..7bce26a9b4c 100644 --- a/desktop/src/features/agents/lib/formatAgentModelLabel.ts +++ b/desktop/src/features/agents/lib/formatAgentModelLabel.ts @@ -1,8 +1,76 @@ +import { + canonicalizeProvider, + DATABRICKS_MODEL_NAMES, + resolveModelCapabilities, +} from "../ui/modelCapabilities"; + +// Re-exported so the label surface remains the single import site for provider +// canonicalization; the interpreter owns the alias map. +export { canonicalizeProvider }; + +/** + * Resolves a human-readable label for a model, following a three-tier + * precedence: + * + * 1. Non-blank discovered/API name (e.g. from `AgentModelInfo.name`) that is + * genuinely distinct from the id. A discovered name that merely echoes the + * trimmed id carries no display information, so it is treated as absent and + * falls through to the registry tier — this covers buzz-agent's Databricks + * discovery contract (`{id, name: id}`) and any harness/version skew that + * echoes the id as the name. + * 2. Registry lookup by id: + * - `provider` supplied → provider-qualified exact record only. On a miss + * the raw id is returned; the unscoped `DATABRICKS_MODEL_NAMES` map is + * NOT consulted, so a Databricks endpoint id never leaks a curated label + * through an anthropic/openai provider context (the P3-B contract). + * - `provider` absent → unscoped `DATABRICKS_MODEL_NAMES` map, for + * legacy/inherited ids with no provider on hand. + * 3. Raw id unchanged. + * + * Returns the empty string when both id and discoveredName are blank; use + * `formatAgentModelLabel` when a null/empty id should render "Auto". + * + * `resolveModelCapabilities` canonicalizes the provider internally, so callers + * pass the raw provider id. Only exact records carry a `registryLabel`, so a + * family/prefix hit yields `null` and correctly falls back to the raw id. + */ +export function resolveModelLabel( + id: string, + discoveredName?: string | null | undefined, + provider?: string | null | undefined, +): string { + const trimmedName = discoveredName?.trim(); + const trimmedId = id.trim(); + // A discovered name distinct from the id is authoritative (tier 1). A name + // that merely echoes the id is treated as absent so the registry tier runs. + if (trimmedName && trimmedName !== trimmedId) return trimmedName; + if (!trimmedId) return ""; + if (provider?.trim()) { + // Provider-qualified exact-record tier (provider-scoped, no unscoped fallback). + const registryLabel = resolveModelCapabilities( + provider, + trimmedId, + ).registryLabel; + return registryLabel ?? trimmedId; + } + // Providerless path: unscoped registry map for legacy/inherited ids. + return DATABRICKS_MODEL_NAMES.get(trimmedId) ?? trimmedId; +} + /** * Returns a human-readable model label for an agent or persona, falling back to * "Auto" when no model is set (empty or whitespace-only). + * + * For known Databricks managed endpoints the registry-curated name is returned + * (e.g. "databricks-gpt-5-5" → "GPT-5.5"). Unknown or custom endpoint ids are + * returned unchanged — no heuristic string mangling. Pass `provider` when the + * inference provider is known to get a provider-qualified registry label. */ -export function formatAgentModelLabel(model: string | null | undefined) { +export function formatAgentModelLabel( + model: string | null | undefined, + provider?: string | null | undefined, +) { const trimmed = model?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : "Auto"; + if (!trimmed) return "Auto"; + return resolveModelLabel(trimmed, null, provider); } diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 11f68e8a564..295c37f23c8 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -48,6 +48,7 @@ import { } from "@/features/agents/ui/agentConfigControls"; import { PersonaProviderApiKeyField } from "@/features/agents/ui/PersonaProviderApiKeyField"; import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery"; +import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; import { BUZZ_AGENT_THINKING_EFFORT, getProviderEffortConfig, @@ -799,7 +800,9 @@ export function AgentConfigFields({ {runtimeSource ? {runtimeSource} : null} - {agent.model ? {agent.model} : null} + {agent.model ? ( + {resolveModelLabel(agent.model, null, agent.provider)} + ) : null} ) : null} diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index f7bafde99b5..863cd851195 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -23,6 +23,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; export function ModelPicker({ agent, @@ -82,13 +83,13 @@ export function ModelPicker({ ); const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? ""; - const displayLabel = - agent.model ?? - (modelsData?.agentDefaultModel - ? `${modelsData.agentDefaultModel} (default)` + const displayLabel = agent.model + ? resolveModelLabel(agent.model, null, agent.provider) + : modelsData?.agentDefaultModel + ? `${resolveModelLabel(modelsData.agentDefaultModel, null, agent.provider)} (default)` : hasRequestedModels && loading ? "Loading..." - : "Auto"); + : "Auto"; // Provenance label shown only for post-spawn agents where the model origin // is known from the config surface and the source is not a user-explicit @@ -221,7 +222,9 @@ export function ModelPicker({
{agent.model ? ( <> -

{agent.model}

+

+ {resolveModelLabel(agent.model, null, agent.provider)} +

This runtime does not support switching models.

@@ -237,7 +240,7 @@ export function ModelPicker({ > {modelsData.models.map((model) => ( - {model.name ?? model.id} + {resolveModelLabel(model.id, model.name, agent.provider)} ))} diff --git a/desktop/src/features/agents/ui/TeamIdentityCard.tsx b/desktop/src/features/agents/ui/TeamIdentityCard.tsx index 19596b76d6c..8e4b02c9e8d 100644 --- a/desktop/src/features/agents/ui/TeamIdentityCard.tsx +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -203,7 +203,7 @@ function TeamAvatarItem({ function getTeamFooterModelLabel(personas: AgentPersona[]) { const modelLabels = personas - .map((persona) => formatAgentModelLabel(persona.model)) + .map((persona) => formatAgentModelLabel(persona.model, persona.provider)) .filter((model): model is string => Boolean(model)); if (modelLabels.length === 0) return "Auto"; diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 47cb78c605f..c6b1821ce1d 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -254,6 +254,7 @@ function AgentPersonaCard({ const modelLabel = resolveAgentCardModelLabel({ agent, personaModel: persona.model, + provider: persona.provider, defaultModel, }); const isActive = agent ? isManagedAgentActive(agent) : false; @@ -393,6 +394,7 @@ function StandaloneAgentCard({ modelLabel={resolveAgentCardModelLabel({ agent, personaModel: null, + provider: agent.provider, defaultModel, })} onClick={() => { diff --git a/desktop/src/features/agents/ui/buzzAgentConfig.test.mjs b/desktop/src/features/agents/ui/buzzAgentConfig.test.mjs index 4d702966b72..f0dd162f5be 100644 --- a/desktop/src/features/agents/ui/buzzAgentConfig.test.mjs +++ b/desktop/src/features/agents/ui/buzzAgentConfig.test.mjs @@ -395,14 +395,15 @@ test("openai gpt-5-pro is not matched by gpt-5 base bucket", () => { ); }); -// gpt-5.10 must NOT match gpt-5.1 (digit boundary) -test("openai gpt-5.10 is not matched by gpt-5.1 token", () => { - const { validValues } = getProviderEffortConfig("openai", "gpt-5.10"); - // gpt-5.10 doesn't match any specific family → falls into unknown table - assert.deepEqual( - [...validValues], - ["none", "minimal", "low", "medium", "high", "xhigh"], +// gpt-5.10 must NOT match gpt-5.1 (digit boundary), but DOES match the gpt-5 +// base rule at the dot boundary → base table, not the unknown fallback. +test("openai gpt-5.10 rejects gpt-5.1 at the digit boundary and matches the gpt-5 base rule", () => { + const { validValues, defaultValue } = getProviderEffortConfig( + "openai", + "gpt-5.10", ); + assert.deepEqual([...validValues], ["minimal", "low", "medium", "high"]); + assert.equal(defaultValue, "medium"); }); // --------------------------------------------------------------------------- @@ -449,8 +450,9 @@ test("databricks_v2 with databricks-gpt-5.1 strips prefix and routes to OpenAI g }); test("databricks_v2 with concrete non-claude non-gpt model excludes max (MLflow clamps it)", () => { - // llama-3 routes through MlflowChatCompletions → normalize_effort_for_openai_route - // → max is clamped to xhigh. Show all-except-max so the UI is honest. + // llama-3 falls to the databricks_v2 concrete-unknown fallback, whose + // OpenaiClampMaxToXhigh normalization policy clamps max→xhigh + // (normalize_effort_for_databricks_v2). Show all-except-max so the UI is honest. const { validValues, defaultValue } = getProviderEffortConfig( "databricks_v2", "llama-3", @@ -541,12 +543,17 @@ test("databricks v1 routes like openai unknown (no gpt-5 model)", () => { assert.equal(defaultValue, "medium"); }); -test("openai-compat returns all-7 with medium default", () => { +test("openai-compat returns all-except-max with medium default", () => { + // openai-compat canonicalizes to openai, whose blank/unknown fallback omits + // max (the OpenAI wire route clamps max → xhigh, so the UI stays honest). const { validValues, defaultValue } = getProviderEffortConfig( "openai-compat", "", ); - assert.equal(validValues.length, 7); + assert.deepEqual( + [...validValues], + ["none", "minimal", "low", "medium", "high", "xhigh"], + ); assert.equal(defaultValue, "medium"); }); diff --git a/desktop/src/features/agents/ui/buzzAgentConfig.ts b/desktop/src/features/agents/ui/buzzAgentConfig.ts index be663c35cb4..d7afe937196 100644 --- a/desktop/src/features/agents/ui/buzzAgentConfig.ts +++ b/desktop/src/features/agents/ui/buzzAgentConfig.ts @@ -1,9 +1,19 @@ /** * Source-of-truth constants for buzz-agent model-tuning configuration knobs. * - * Values must stay in sync with `crates/buzz-agent/src/config.rs` - * `parse_thinking_effort` — that function is the authoritative list. + * The thinking-effort value list and the provider/model → effort projection are + * both derived from the shared capability manifest via the interpreter in + * `./modelCapabilities`; this module owns only the buzz-agent env-var keys and + * the runtime-id guard. Mirrors the `config.rs` ⇄ `model_capabilities.rs` seam + * in `crates/buzz-agent`, where effort resolution is delegated to the manifest. + * (The interpreter owns the value list rather than the reverse, because it uses + * the values at module-load for zod — the acyclic direction.) */ +import { + THINKING_EFFORT_VALUES, + type ThinkingEffortValue, + resolveModelCapabilities, +} from "./modelCapabilities"; /** Env var key for the thinking/effort level sent to the LLM. */ export const BUZZ_AGENT_THINKING_EFFORT = "BUZZ_AGENT_THINKING_EFFORT"; @@ -19,24 +29,12 @@ export const BUZZ_AGENT_MAX_ROUNDS = "BUZZ_AGENT_MAX_ROUNDS"; /** * Ordered set of valid thinking-effort values accepted by buzz-agent. - * Mirrors `parse_thinking_effort` in `crates/buzz-agent/src/config.rs`. + * Re-exported from the manifest interpreter, which owns the canonical list + * (mirrors `parse_thinking_effort` in `crates/buzz-agent/src/config.rs`). */ -export const BUZZ_AGENT_THINKING_EFFORT_VALUES = [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -] as const; +export const BUZZ_AGENT_THINKING_EFFORT_VALUES = THINKING_EFFORT_VALUES; -export type ThinkingEffortValue = - (typeof BUZZ_AGENT_THINKING_EFFORT_VALUES)[number]; - -// --------------------------------------------------------------------------- -// Provider-aware effort configuration -// --------------------------------------------------------------------------- +export type { ThinkingEffortValue }; /** * Describes which thinking-effort values are valid for a given provider+model, @@ -44,12 +42,8 @@ export type ThinkingEffortValue = * * `defaultValue = null` means the provider/model's default is to omit the * thinking configuration entirely (i.e. "Inherit" is the natural default). - * This applies to Anthropic manual-budget models where the effort level maps - * to a budget_tokens count — there is no "default effort level" in the API. - * - * Mirrors the model-family tables in `crates/buzz-agent/src/config.rs` - * (`openai_efforts_for_model`, `is_manual_budget_model`, - * `is_adaptive_thinking_model`, `clamp_adaptive_effort`). Keep in sync. + * This applies to Anthropic manual-budget models, whose effort maps to a + * budget_tokens count — there is no "default effort level" in the API. */ export type ProviderEffortConfig = { validValues: ReadonlyArray; @@ -57,246 +51,23 @@ export type ProviderEffortConfig = { defaultValue: ThinkingEffortValue | null; }; -const ALL_VALUES = BUZZ_AGENT_THINKING_EFFORT_VALUES; - /** - * Returns the valid thinking-effort values and semantic default for the - * given provider and optional model string. + * Returns the valid thinking-effort values and semantic default for the given + * provider and optional model, projected from the shared capability manifest. * - * Model matching mirrors the Rust backend: - * - Anthropic: strip any endpoint-naming prefix, then test `is_manual_budget_model` - * / `is_adaptive_thinking_model` / `clamp_adaptive_effort` family checks. - * - OpenAI: strip any endpoint-naming prefix, then test `openai_efforts_for_model` - * family checks (boundary-aware: -pro before -5.x, digit/letter boundary). - * - DatabricksV2: strip prefix and route by model family. - * - Unknown/empty: all 7 values, default medium. - * - * Prefix stripping: finds the first occurrence of a known model-family token - * (`claude-`, `gpt-`) and drops everything before it. This handles any - * endpoint-naming convention (e.g. `databricks-`, `goose-`, `team-x-`) without - * maintaining an allowlist of known prefixes. If no family token is found, the - * raw model name is used as-is. + * A thin projection over `resolveModelCapabilities`: `validValues` is the + * resolved `supportedEfforts` axis and `defaultValue` is `defaultEffort`. + * Provider canonicalization (alias map) and endpoint-prefix stripping happen + * inside the resolver, so callers pass raw provider/model strings. */ export function getProviderEffortConfig( providerId: string, model?: string, ): ProviderEffortConfig { - const provider = providerId.toLowerCase(); - // Strip arbitrary endpoint-naming prefix before model-family matching. - // Find the first occurrence of a known family token and drop everything before it. - // e.g. "goose-claude-fable-5" → "claude-fable-5" - // "team-x-gpt-5.5" → "gpt-5.5" - // "databricks-claude-3" → "claude-3" - // "claude-opus-4-7" → "claude-opus-4-7" (no prefix to strip) - const rawModel = (model ?? "").trim().toLowerCase(); - const FAMILY_TOKENS = ["claude-", "gpt-"] as const; - const firstFamilyIdx = Math.min( - ...FAMILY_TOKENS.map((tok) => { - const idx = rawModel.indexOf(tok); - return idx === -1 ? Infinity : idx; - }), - ); - const m = - firstFamilyIdx === Infinity ? rawModel : rawModel.slice(firstFamilyIdx); - - if (provider === "anthropic") { - return anthropicConfig(m); - } - if (provider === "openai") { - return openaiConfig(m); - } - if (provider === "databricks_v2") { - // Route by model family: claude* → Anthropic tables, gpt-5* → OpenAI tables. - // Non-Claude concrete models (e.g. llama-3) go through MlflowChatCompletions, - // which applies normalize_effort_for_openai_route → clamps max to xhigh. - // Route them through openaiConfig to exclude max. Only blank/unknown model - // uses the all-7 fallback (can't know the route without a concrete model). - if (m.startsWith("claude-")) { - return anthropicConfig(m); - } - if (gpt5FamilyModel(m)) { - return openaiConfig(m); - } - if (m.length > 0) { - // Concrete non-Claude, non-GPT model → MLflow path clamps max → xhigh. - return openaiConfig(m); - } - // Blank model — route unknown, show all 7. - return { validValues: ALL_VALUES, defaultValue: "medium" }; - } - if (provider === "databricks") { - // databricks v1 uses OpenAI Chat Completions wire format. - return openaiConfig(m); - } - if (provider === "openrouter") { - return { validValues: ALL_VALUES, defaultValue: "medium" }; - } - // openai-compat, unknown, empty — all values, default medium. - return { validValues: ALL_VALUES, defaultValue: "medium" }; -} - -// --------------------------------------------------------------------------- -// Anthropic family tables -// --------------------------------------------------------------------------- - -function anthropicConfig(m: string): ProviderEffortConfig { - // Manual-budget models: claude-3* and claude-opus-4-5. - // These use budget_tokens — there is no "default effort level" in the API. - if (m.startsWith("claude-3") || m === "claude-opus-4-5") { - return { - validValues: ["low", "medium", "high"], - defaultValue: null, - }; - } - // Adaptive models that support xhigh: opus-4-7+, sonnet-5.x, fable-5, mythos-5. - // mirrors clamp_adaptive_effort supports_xhigh check. - if ( - m.startsWith("claude-opus-4-7") || - m.startsWith("claude-opus-4-8") || - m.startsWith("claude-sonnet-5") || - m.startsWith("claude-fable-5") || - m.startsWith("claude-mythos-5") - ) { - return { - validValues: ["low", "medium", "high", "xhigh", "max"], - defaultValue: "high", - }; - } - // Adaptive models that do NOT support xhigh: opus-4-6, sonnet-4-6, mythos-preview. - if ( - m.startsWith("claude-opus-4-6") || - m.startsWith("claude-sonnet-4-6") || - m.startsWith("claude-mythos-preview") - ) { - return { - validValues: ["low", "medium", "high", "max"], - defaultValue: "high", - }; - } - // Unknown Anthropic model — assume adaptive with full support. - return { - validValues: ["low", "medium", "high", "xhigh", "max"], - defaultValue: "high", - }; -} - -// --------------------------------------------------------------------------- -// OpenAI family tables — mirrors openai_efforts_for_model in config.rs -// --------------------------------------------------------------------------- - -/** - * Returns true if `m` contains a GPT-5 family token at a word boundary - * (not immediately followed by a digit or letter). Mirrors - * `gpt5_token_matches` / `gpt5_base_matches` in config.rs. - */ -function gpt5TokenMatches(m: string, token: string): boolean { - let start = 0; - while (true) { - const idx = m.indexOf(token, start); - if (idx === -1) return false; - const afterIdx = idx + token.length; - const afterChar = afterIdx < m.length ? m[afterIdx] : ""; - // Boundary: end-of-string or a `-` separator (not a digit or letter). - if (afterChar === "" || afterChar === "-") return true; - start = afterIdx; - } -} - -/** Like gpt5TokenMatches but also rejects short -<1-3 digit> suffixes (e.g. -5, -10). */ -function gpt5BaseMatches(m: string, token: string): boolean { - let start = 0; - while (true) { - const idx = m.indexOf(token, start); - if (idx === -1) return false; - const afterIdx = idx + token.length; - const suffix = m.slice(afterIdx); - if (suffix === "") return true; - if (!suffix.startsWith("-")) { - start = afterIdx; - continue; - } - // Has a `-` suffix — check if it looks like a 1-3 digit version number. - const dashRest = suffix.slice(1); - if (/^\d{1,3}(?:[^a-z\d]|$)/i.test(dashRest)) { - start = afterIdx; - continue; - } - return true; - } -} - -/** Returns true if the model string belongs to any GPT-5 family. */ -function gpt5FamilyModel(m: string): boolean { - return ( - gpt5TokenMatches(m, "gpt-5-pro") || - gpt5TokenMatches(m, "gpt5-pro") || - gpt5TokenMatches(m, "gpt-5.6") || - gpt5TokenMatches(m, "gpt5.6") || - gpt5TokenMatches(m, "gpt-5-6") || - gpt5TokenMatches(m, "gpt5-6") || - gpt5TokenMatches(m, "gpt-5.5") || - gpt5TokenMatches(m, "gpt5.5") || - gpt5TokenMatches(m, "gpt-5.4") || - gpt5TokenMatches(m, "gpt5.4") || - gpt5TokenMatches(m, "gpt-5.1") || - gpt5TokenMatches(m, "gpt5.1") || - gpt5BaseMatches(m, "gpt-5") || - gpt5BaseMatches(m, "gpt5") - ); -} - -function openaiConfig(m: string): ProviderEffortConfig { - // Check -pro before versioned suffixes (gpt-5-pro contains "gpt-5"). - if (gpt5TokenMatches(m, "gpt-5-pro") || gpt5TokenMatches(m, "gpt5-pro")) { - return { validValues: ["high"], defaultValue: "high" }; - } - if ( - gpt5TokenMatches(m, "gpt-5.6") || - gpt5TokenMatches(m, "gpt5.6") || - gpt5TokenMatches(m, "gpt-5-6") || - gpt5TokenMatches(m, "gpt5-6") - ) { - return { - validValues: ["none", "low", "medium", "high", "xhigh", "max"], - defaultValue: "medium", - }; - } - if ( - gpt5TokenMatches(m, "gpt-5.5") || - gpt5TokenMatches(m, "gpt5.5") || - gpt5TokenMatches(m, "gpt-5-5") || - gpt5TokenMatches(m, "gpt5-5") || - gpt5TokenMatches(m, "gpt-5.4") || - gpt5TokenMatches(m, "gpt5.4") || - gpt5TokenMatches(m, "gpt-5-4") || - gpt5TokenMatches(m, "gpt5-4") - ) { - return { - validValues: ["none", "low", "medium", "high", "xhigh"], - defaultValue: "medium", - }; - } - if ( - gpt5TokenMatches(m, "gpt-5.1") || - gpt5TokenMatches(m, "gpt5.1") || - gpt5TokenMatches(m, "gpt-5-1") || - gpt5TokenMatches(m, "gpt5-1") - ) { - return { - validValues: ["none", "low", "medium", "high"], - defaultValue: "none", - }; - } - if (gpt5BaseMatches(m, "gpt-5") || gpt5BaseMatches(m, "gpt5")) { - return { - validValues: ["minimal", "low", "medium", "high"], - defaultValue: "medium", - }; - } - // Unknown OpenAI model — conservative fallback; max is enabled only for families whose table includes it. + const cap = resolveModelCapabilities(providerId, model ?? ""); return { - validValues: ["none", "minimal", "low", "medium", "high", "xhigh"], - defaultValue: "medium", + validValues: cap.supportedEfforts, + defaultValue: cap.defaultEffort, }; } diff --git a/desktop/src/features/agents/ui/effortTable.fixture.json b/desktop/src/features/agents/ui/effortTable.fixture.json deleted file mode 100644 index d097bc995f6..00000000000 --- a/desktop/src/features/agents/ui/effortTable.fixture.json +++ /dev/null @@ -1,254 +0,0 @@ -[ - { - "note": "Anthropic manual-budget: claude-3 family", - "provider": "anthropic", - "model": "claude-3-7-sonnet-20250219", - "validValues": ["low", "medium", "high"], - "defaultValue": null - }, - { - "note": "Anthropic manual-budget: claude-opus-4-5", - "provider": "anthropic", - "model": "claude-opus-4-5", - "validValues": ["low", "medium", "high"], - "defaultValue": null - }, - { - "note": "Anthropic adaptive xhigh-capable: claude-opus-4-7", - "provider": "anthropic", - "model": "claude-opus-4-7", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "Anthropic adaptive xhigh-capable: claude-opus-4-8", - "provider": "anthropic", - "model": "claude-opus-4-8", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "Anthropic adaptive xhigh-capable: claude-sonnet-5", - "provider": "anthropic", - "model": "claude-sonnet-5-20260101", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "Anthropic adaptive xhigh-capable: claude-fable-5", - "provider": "anthropic", - "model": "claude-fable-5", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "Anthropic adaptive xhigh-capable: claude-opus-5", - "provider": "anthropic", - "model": "claude-opus-5", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "Anthropic adaptive xhigh-capable: claude-mythos-5", - "provider": "anthropic", - "model": "claude-mythos-5", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "Anthropic adaptive no-xhigh: claude-opus-4-6", - "provider": "anthropic", - "model": "claude-opus-4-6", - "validValues": ["low", "medium", "high", "max"], - "defaultValue": "high" - }, - { - "note": "Anthropic adaptive no-xhigh: claude-sonnet-4-6", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - "validValues": ["low", "medium", "high", "max"], - "defaultValue": "high" - }, - { - "note": "Anthropic adaptive no-xhigh: claude-mythos-preview", - "provider": "anthropic", - "model": "claude-mythos-preview", - "validValues": ["low", "medium", "high", "max"], - "defaultValue": "high" - }, - { - "note": "Anthropic unknown model: blank \u2014 assume full adaptive", - "provider": "anthropic", - "model": "", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "OpenAI gpt-5-pro: high only", - "provider": "openai", - "model": "gpt-5-pro", - "validValues": ["high"], - "defaultValue": "high" - }, - { - "note": "OpenAI gpt-5.6: none/low/medium/high/xhigh/max", - "provider": "openai", - "model": "gpt-5.6", - "validValues": ["none", "low", "medium", "high", "xhigh", "max"], - "defaultValue": "medium" - }, - { - "note": "OpenAI gpt-5.5: none/low/medium/high/xhigh", - "provider": "openai", - "model": "gpt-5.5", - "validValues": ["none", "low", "medium", "high", "xhigh"], - "defaultValue": "medium" - }, - { - "note": "OpenAI gpt-5.4: same table as gpt-5.5", - "provider": "openai", - "model": "gpt-5.4", - "validValues": ["none", "low", "medium", "high", "xhigh"], - "defaultValue": "medium" - }, - { - "note": "OpenAI gpt-5.1: none/low/medium/high", - "provider": "openai", - "model": "gpt-5.1", - "validValues": ["none", "low", "medium", "high"], - "defaultValue": "none" - }, - { - "note": "OpenAI gpt-5 base: minimal/low/medium/high", - "provider": "openai", - "model": "gpt-5", - "validValues": ["minimal", "low", "medium", "high"], - "defaultValue": "medium" - }, - { - "note": "OpenAI unknown model (gpt-4o): all-except-max", - "provider": "openai", - "model": "gpt-4o", - "validValues": ["none", "minimal", "low", "medium", "high", "xhigh"], - "defaultValue": "medium" - }, - { - "note": "OpenAI empty model: all-except-max", - "provider": "openai", - "model": "", - "validValues": ["none", "minimal", "low", "medium", "high", "xhigh"], - "defaultValue": "medium" - }, - { - "note": "DatabricksV2 claude route (claude-opus-4-7): xhigh-capable anthropic table", - "provider": "databricks_v2", - "model": "claude-opus-4-7", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "DatabricksV2 claude route with databricks- prefix stripped", - "provider": "databricks_v2", - "model": "databricks-claude-opus-4-7", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "DatabricksV2 gpt-5.6-sol route: OpenAI max-capable table", - "provider": "databricks_v2", - "model": "gpt-5.6-sol", - "validValues": ["none", "low", "medium", "high", "xhigh", "max"], - "defaultValue": "medium" - }, - { - "note": "DatabricksV2 gpt-5-6-sol route: dashed OpenAI max-capable table", - "provider": "databricks_v2", - "model": "gpt-5-6-sol", - "validValues": ["none", "low", "medium", "high", "xhigh", "max"], - "defaultValue": "medium" - }, - { - "note": "DatabricksV2 gpt-5.4 route: OpenAI gpt-5.5/5.4 table", - "provider": "databricks_v2", - "model": "gpt-5.4", - "validValues": ["none", "low", "medium", "high", "xhigh"], - "defaultValue": "medium" - }, - { - "note": "DatabricksV2 gpt-5.1 with databricks- prefix: OpenAI gpt-5.1 table", - "provider": "databricks_v2", - "model": "databricks-gpt-5.1", - "validValues": ["none", "low", "medium", "high"], - "defaultValue": "none" - }, - { - "note": "DatabricksV2 concrete non-claude non-gpt5 (llama-3): MLflow path, all-except-max", - "provider": "databricks_v2", - "model": "llama-3", - "validValues": ["none", "minimal", "low", "medium", "high", "xhigh"], - "defaultValue": "medium" - }, - { - "note": "DatabricksV2 blank model: route unknown, all-7", - "provider": "databricks_v2", - "model": "", - "validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], - "defaultValue": "medium" - }, - { - "note": "databricks v1: routes like openai unknown, all-except-max", - "provider": "databricks", - "model": "", - "validValues": ["none", "minimal", "low", "medium", "high", "xhigh"], - "defaultValue": "medium" - }, - { - "note": "openai-compat: all-7 with medium default", - "provider": "openai-compat", - "model": "", - "validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], - "defaultValue": "medium" - }, - { - "note": "openrouter: all-7 with medium default", - "provider": "openrouter", - "model": "", - "validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], - "defaultValue": "medium" - }, - { - "note": "empty provider: all-7 with medium default", - "provider": "", - "model": "", - "validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], - "defaultValue": "medium" - }, - { - "note": "databricks_v2 goose-claude-fable-5: strips goose- prefix, routes anthropic adaptive+xhigh, max valid", - "provider": "databricks_v2", - "model": "goose-claude-fable-5", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "databricks_v2 goose-gpt-5.5: strips goose- prefix, routes openai gpt-5.5 table (none+low-xhigh, no minimal)", - "provider": "databricks_v2", - "model": "goose-gpt-5.5", - "validValues": ["none", "low", "medium", "high", "xhigh"], - "defaultValue": "medium" - }, - { - "note": "databricks_v2 goose-claude-sonnet-5: strips goose- prefix, routes anthropic adaptive+xhigh", - "provider": "databricks_v2", - "model": "goose-claude-sonnet-5", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - }, - { - "note": "databricks_v2 arbitrary prefix team-x-claude-opus-4-7: strips to claude-opus-4-7, routes anthropic adaptive+xhigh, max valid", - "provider": "databricks_v2", - "model": "team-x-claude-opus-4-7", - "validValues": ["low", "medium", "high", "xhigh", "max"], - "defaultValue": "high" - } -] diff --git a/desktop/src/features/agents/ui/effortTable.fixture.test.mjs b/desktop/src/features/agents/ui/effortTable.fixture.test.mjs deleted file mode 100644 index c63b94915e3..00000000000 --- a/desktop/src/features/agents/ui/effortTable.fixture.test.mjs +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Effort-table sync guard: TS side. - * - * Loads the checked-in fixture and asserts that `getProviderEffortConfig` - * matches every entry. Drift between `buzzAgentConfig.ts` and the fixture - * (e.g. a new model family added to one side but not the other) fails CI. - * The companion Rust test in `crates/buzz-agent/src/config.rs` mirrors - * this check so both sides of the mirror must stay in sync. - */ - -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; - -import { getProviderEffortConfig } from "./buzzAgentConfig.ts"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixture = JSON.parse( - readFileSync(path.join(__dirname, "effortTable.fixture.json"), "utf8"), -); - -for (const entry of fixture) { - const { - note, - provider, - model, - validValues: expectedValidValues, - defaultValue: expectedDefault, - } = entry; - const label = note ?? `${provider}/${model}`; - - test(`effort fixture: ${label}`, () => { - const { validValues, defaultValue } = getProviderEffortConfig( - provider, - model, - ); - - assert.deepEqual( - [...validValues], - expectedValidValues, - `validValues mismatch for "${label}"`, - ); - - assert.equal( - defaultValue, - expectedDefault, - `defaultValue mismatch for "${label}"`, - ); - }); -} diff --git a/desktop/src/features/agents/ui/modelCapabilities.ts b/desktop/src/features/agents/ui/modelCapabilities.ts new file mode 100644 index 00000000000..bd160c7b810 --- /dev/null +++ b/desktop/src/features/agents/ui/modelCapabilities.ts @@ -0,0 +1,372 @@ +/** + * Runtime model-capability interpreter (TypeScript). + * + * `scripts/model-capabilities.json` is the single source of truth for every + * model's six-axis capability profile (thinking mode, supported efforts, + * default effort, Databricks v2 wire route, normalization policy, and picker + * label). This module imports that manifest and interprets it at runtime, + * mirroring the Rust interpreter in `crates/buzz-agent/src/model_capabilities.rs` + * line for line. There is no codegen: both interpreters read the same + * hand-curated manifest, and the shared normative corpus + * (`scripts/normative-corpus.json`) is the cross-language contract that + * guarantees they agree. + * + * ## Resolution algorithm (`resolveModelCapabilities`) + * 1. Provider canonicalization (trim, lowercase, alias map) — done by the + * caller via `canonicalizeProvider`. + * 2. Provider-qualified exact-record lookup (case-insensitive on the id). + * 3. Boundary-aware family-rule match: strip any endpoint prefix at the first + * family token on a non-alphanumeric boundary, then take the longest match + * across every rule's `matchValue` and `matchAliases`, breaking ties on the + * lexicographically smallest rule id. + * 4. Provider fallback, distinguishing a blank model id from a + * concrete-unknown one. + * + * Every path yields a complete six-axis result; `registryLabel` is populated + * only on an exact-record hit. + */ +import { z } from "zod"; +import manifestJson from "@model-capabilities-manifest"; + +/** Valid thinking-effort values accepted by buzz-agent (mirrors parse_thinking_effort in config.rs). */ +export const THINKING_EFFORT_VALUES = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; +export type ThinkingEffortValue = (typeof THINKING_EFFORT_VALUES)[number]; + +/** Databricks v2 wire route. `not-applicable` for non-DBv2 providers. */ +export type DatabricksV2WireRoute = + | "openai-responses" + | "anthropic-messages" + | "mlflow-chat" + | "route-unknown" + | "not-applicable"; + +/** How a model activates and controls reasoning depth on the wire. */ +export type ThinkingMode = + | "adaptive" + | "manual-budget" + | "none" + | "omit-fields"; + +/** Post-resolution effort normalization applied before a request is sent. */ +export type NormalizationPolicy = + | "none" + | "openai-standard" + | "openai-clamp-max-to-xhigh"; + +/** Complete resolved capability record for a (provider, rawModelId) pair. Every axis populated. */ +export type CapabilityResult = { + readonly thinkingMode: ThinkingMode; + readonly supportedEfforts: ReadonlyArray; + readonly defaultEffort: ThinkingEffortValue | null; + readonly databricksV2WireRoute: DatabricksV2WireRoute; + readonly normalizationPolicy: NormalizationPolicy; + /** Static display label. Populated only on a provider-qualified exact-record hit. */ + readonly registryLabel: string | null; +}; + +// --------------------------------------------------------------------------- +// Manifest schema — runtime-validates the bundled manifest, mirroring the +// strict serde (`deny_unknown_fields` + real enums) + validate_manifest in the +// Rust interpreter. A malformed bundled manifest is a build-time data error +// that must never ship, so parse failure throws. +// --------------------------------------------------------------------------- + +const EffortSchema = z.enum(THINKING_EFFORT_VALUES); +const ThinkingModeSchema = z.enum([ + "adaptive", + "manual-budget", + "none", + "omit-fields", +]); +const WireRouteSchema = z.enum([ + "openai-responses", + "anthropic-messages", + "mlflow-chat", + "route-unknown", + "not-applicable", +]); +const NormalizationSchema = z.enum([ + "none", + "openai-standard", + "openai-clamp-max-to-xhigh", +]); + +const FamilyRuleSchema = z + .object({ + id: z.string(), + match_kind: z.enum(["exact", "prefix"]), + match_value: z.string(), + match_aliases: z.array(z.string()).default([]), + providers: z.array(z.string()), + thinking_mode: ThinkingModeSchema, + supported_efforts: z.array(EffortSchema), + default_effort: EffortSchema.nullable(), + databricks_v2_wire_route: WireRouteSchema, + normalization_policy: NormalizationSchema, + // Documentation-only key; modeled so strict parsing accepts the manifest + // while still rejecting an unmodeled (typo'd) field. Mirrors the Rust + // `FamilyRule` doc field under `deny_unknown_fields`. + _comment: z.string().optional(), + }) + .strict(); + +const ExactRecordSchema = z + .object({ + provider: z.string(), + raw_model_id: z.string(), + registry_label: z.string(), + thinking_mode: ThinkingModeSchema, + supported_efforts: z.array(EffortSchema), + default_effort: EffortSchema.nullable(), + databricks_v2_wire_route: WireRouteSchema, + normalization_policy: NormalizationSchema, + // Documentation/provenance keys; modeled for strict parsing, not read at + // runtime. Mirrors the Rust `ExactRecord` doc fields under + // `deny_unknown_fields`. + _provenance: z.string().optional(), + source: z.string().optional(), + _source: z.string().optional(), + _reconciliation: z.string().optional(), + _reconciliation_note: z.string().optional(), + _reconciliation_doc: z.string().optional(), + }) + .strict(); + +const FallbackStateSchema = z + .object({ + databricks_v2_wire_route: WireRouteSchema, + thinking_mode: ThinkingModeSchema, + supported_efforts: z.array(EffortSchema), + default_effort: EffortSchema.nullable(), + normalization_policy: NormalizationSchema, + }) + .strict(); + +const FallbackPairSchema = z + .object({ + blank: FallbackStateSchema, + concrete_unknown: FallbackStateSchema, + }) + .strict(); + +// Fixed named providers with a `_default` catch-all, mirroring the Rust +// `ProviderFallbacks` struct. Enumerating the keys structurally guarantees +// `_default` is present (the resolver's total-function backstop). +const ProviderFallbacksSchema = z + .object({ + anthropic: FallbackPairSchema, + openai: FallbackPairSchema, + databricks: FallbackPairSchema, + databricks_v2: FallbackPairSchema, + openrouter: FallbackPairSchema, + _default: FallbackPairSchema, + }) + .strict(); + +export const ManifestSchema = z + .object({ + family_tokens: z.array(z.string()).min(1), + family_rules: z.array(FamilyRuleSchema), + databricks_v2_known_models: z.array(z.string()), + exact_records: z.array(ExactRecordSchema), + provider_fallbacks: ProviderFallbacksSchema, + // Root documentation keys; modeled for strict parsing, not read at runtime. + // Mirrors the Rust `Manifest` doc fields under `deny_unknown_fields`. + _comment: z.string().optional(), + _comment_databricks_v2_known_models: z.string().optional(), + _sources: z.record(z.string(), z.string()).optional(), + }) + .strict(); + +type ParsedManifest = z.infer; +type FamilyRule = z.infer; +type FallbackState = z.infer; +type FallbackPair = z.infer; + +const MANIFEST: ParsedManifest = ManifestSchema.parse(manifestJson); + +// Prototype-safe provider→fallback lookup. A plain-object index would return +// `Object.prototype.constructor` / `Object.prototype` for the adversarial +// providers `constructor` / `__proto__`, defeating the `_default` catch-all; +// a Map keys only on real entries. Mirrors `ProviderFallbacks::get`. +const PROVIDER_FALLBACKS: ReadonlyMap = new Map( + Object.entries(MANIFEST.provider_fallbacks), +); + +function fallbackPair(canon: string): FallbackPair { + // `_default` is a required key on `ProviderFallbacksSchema`, so referencing + // it directly is typed as a non-optional `FallbackPair` — no assertion, and + // the total-function backstop is guaranteed by the schema, not by `!`. + return PROVIDER_FALLBACKS.get(canon) ?? MANIFEST.provider_fallbacks._default; +} + +// --------------------------------------------------------------------------- +// Provider canonicalization +// --------------------------------------------------------------------------- + +const PROVIDER_ALIASES = new Map([ + ["openai-compat", "openai"], + ["databricks-v2", "databricks_v2"], +]); + +/** Canonicalize a provider name: trim, lowercase, apply the alias map. */ +export function canonicalizeProvider(provider: string): string { + const canon = provider.trim().toLowerCase(); + return PROVIDER_ALIASES.get(canon) ?? canon; +} + +// --------------------------------------------------------------------------- +// Boundary-aware prefix helpers — mirror strip_catalog_prefix / prefix_matches. +// --------------------------------------------------------------------------- + +function isAsciiAlphanumeric(ch: string): boolean { + return /^[a-z0-9]$/i.test(ch); +} + +/** + * Strip an endpoint-naming prefix by locating the earliest family token that + * begins on a non-alphanumeric boundary (or at the start), returning the slice + * from that token onward. Returns the input unchanged when no token qualifies. + */ +export function stripCatalogPrefix( + modelLower: string, + familyTokens: ReadonlyArray, +): string { + let best = Number.POSITIVE_INFINITY; + for (const tok of familyTokens) { + let from = 0; + while (true) { + const idx = modelLower.indexOf(tok, from); + if (idx === -1) break; + if (idx === 0 || !isAsciiAlphanumeric(modelLower[idx - 1])) { + if (idx < best) best = idx; + break; + } + from = idx + 1; + } + } + return best === Number.POSITIVE_INFINITY + ? modelLower + : modelLower.slice(best); +} + +/** + * Boundary-aware prefix test: `s` equals `token`, or `s` starts with `token` + * and the following character is a non-alphanumeric boundary. + */ +function prefixMatches(token: string, s: string): boolean { + if (!s.startsWith(token)) return false; + const rest = s.slice(token.length); + return rest.length === 0 || !isAsciiAlphanumeric(rest[0]); +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +function toResult( + axes: FamilyRule | FallbackState, + route: DatabricksV2WireRoute, + registryLabel: string | null, +): CapabilityResult { + return { + thinkingMode: axes.thinking_mode, + supportedEfforts: axes.supported_efforts, + defaultEffort: axes.default_effort, + databricksV2WireRoute: route, + normalizationPolicy: axes.normalization_policy, + registryLabel, + }; +} + +/** + * Resolve the capability profile for a `(provider, rawModelId)` pair. + * + * Total function — always returns a complete result. Provider canonicalization + * happens inside the resolver (trim, lowercase, alias map), so callers pass raw + * provider names. Mirrors `resolve` in the Rust interpreter exactly. + */ +export function resolveModelCapabilities( + provider: string, + rawModelId: string, +): CapabilityResult { + const canon = canonicalizeProvider(provider); + const blank = rawModelId.trim().length === 0; + + // 1. Provider-qualified exact-record lookup (case-insensitive on the id). + if (!blank) { + const idLower = rawModelId.toLowerCase(); + for (const rec of MANIFEST.exact_records) { + if ( + rec.provider === canon && + rec.raw_model_id.toLowerCase() === idLower + ) { + return toResult(rec, rec.databricks_v2_wire_route, rec.registry_label); + } + } + } + + // 2. Boundary-aware family match: longest token wins, lexicographic tie-break. + if (!blank) { + const modelLower = rawModelId.toLowerCase(); + const stripped = stripCatalogPrefix(modelLower, MANIFEST.family_tokens); + let best: { len: number; rule: FamilyRule } | null = null; + for (const rule of MANIFEST.family_rules) { + if (!rule.providers.includes(canon)) continue; + let matched: number | null = null; + for (const tok of [rule.match_value, ...rule.match_aliases]) { + const ok = + rule.match_kind === "exact" + ? stripped === tok + : prefixMatches(tok, stripped); + if (ok) + matched = + matched === null ? tok.length : Math.max(matched, tok.length); + } + if (matched !== null) { + const better = + best === null || + matched > best.len || + (matched === best.len && rule.id < best.rule.id); + if (better) best = { len: matched, rule }; + } + } + if (best !== null) { + const route: DatabricksV2WireRoute = + canon === "databricks_v2" + ? best.rule.databricks_v2_wire_route + : "not-applicable"; + return toResult(best.rule, route, null); + } + } + + // 3. Provider fallback (blank vs. concrete-unknown); never carries a label. + const pair = fallbackPair(canon); + const state = blank ? pair.blank : pair.concrete_unknown; + return toResult(state, state.databricks_v2_wire_route, null); +} + +/** Authoritative list of known Databricks v2 model ids, sourced from the manifest. */ +export const DATABRICKS_V2_KNOWN_MODELS: ReadonlyArray = + MANIFEST.databricks_v2_known_models; + +/** + * Databricks endpoint-id → display-name registry, derived at runtime from the + * manifest's `databricks_v2` exact records (the only exact records that carry a + * `registry_label`). Feeds the providerless registry tier of + * `resolveModelLabel`. Derived, not hand-listed — the manifest stays the single + * source of truth, so there is no second table to keep in sync. + */ +export const DATABRICKS_MODEL_NAMES: ReadonlyMap = new Map( + MANIFEST.exact_records + .filter((rec) => rec.provider === "databricks_v2") + .map((rec) => [rec.raw_model_id, rec.registry_label] as const), +); diff --git a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs new file mode 100644 index 00000000000..52f1f0ecf0e --- /dev/null +++ b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + ManifestSchema, + resolveModelCapabilities, +} from "./modelCapabilities.ts"; + +// The normative corpus (`scripts/normative-corpus.json`) is the cross-language +// contract: the Rust interpreter's test suite runs the same executable vectors +// through its `resolve`, so a green run here proves the TS interpreter agrees +// with Rust axis-for-axis. Loaded by relative path — the corpus never passes +// through vite/tsc, so it needs no import alias. +const corpusUrl = new URL( + "../../../../../scripts/normative-corpus.json", + import.meta.url, +); +const corpus = JSON.parse(readFileSync(fileURLToPath(corpusUrl), "utf8")); + +// A vector is executable iff it carries an `expect` block; section markers +// (`_group`) are skipped. Mirrors the Rust corpus filter. +const executable = corpus.filter((entry) => entry.expect != null); + +test("corpus has exactly 103 executable vectors", () => { + // Locks the vector count so a silent corpus edit can't quietly drop coverage; + // must equal the gate in the Rust suite (model_capabilities.rs). + assert.equal(executable.length, 103); +}); + +test("every executable corpus vector resolves to its expected six-axis profile", () => { + for (const entry of executable) { + const id = entry.id ?? ""; + const got = resolveModelCapabilities( + entry.provider ?? "", + entry.raw_model_id ?? "", + ); + const want = entry.expect; + assert.equal(got.thinkingMode, want.thinking_mode, `${id}: thinkingMode`); + assert.deepEqual( + [...got.supportedEfforts], + want.supported_efforts, + `${id}: supportedEfforts`, + ); + assert.equal( + got.defaultEffort, + want.default_effort, + `${id}: defaultEffort`, + ); + assert.equal( + got.databricksV2WireRoute, + want.databricks_v2_wire_route, + `${id}: databricksV2WireRoute`, + ); + assert.equal( + got.normalizationPolicy, + want.normalization_policy, + `${id}: normalizationPolicy`, + ); + assert.equal( + got.registryLabel, + want.registry_label, + `${id}: registryLabel`, + ); + } +}); + +test("registryLabel axis is exercised by at least 12 exact-record vectors", () => { + // The registryLabel axis only populates on an exact-record hit; guard that + // the corpus keeps covering it so a regression there can't pass unnoticed. + const labeled = executable.filter((e) => e.expect.registry_label != null); + assert.ok( + labeled.length >= 12, + `expected >=12 labeled vectors, got ${labeled.length}`, + ); + for (const entry of labeled) { + const got = resolveModelCapabilities( + entry.provider ?? "", + entry.raw_model_id ?? "", + ); + assert.equal( + got.registryLabel, + entry.expect.registry_label, + `${entry.id ?? ""}: registryLabel`, + ); + } +}); + +// The TS manifest schema mirrors Rust's `#[serde(deny_unknown_fields)]`: a +// misspelled key must fail in BOTH languages, not pass silently on desktop. +// Loaded by relative path — same rationale as the corpus above. +const manifestUrl = new URL( + "../../../../../scripts/model-capabilities.json", + import.meta.url, +); +const manifestJson = JSON.parse( + readFileSync(fileURLToPath(manifestUrl), "utf8"), +); + +test("ManifestSchema accepts the committed manifest verbatim", () => { + // The strict schema must model every documented key the manifest actually + // ships (`_comment`, `_provenance`, `source`, `_sources`, …); a green parse + // here proves strictness didn't over-reach and break the real data. + assert.doesNotThrow(() => ManifestSchema.parse(manifestJson)); +}); + +test("ManifestSchema rejects an unknown top-level field", () => { + // Mirrors Rust `deny_unknown_fields`: a typo'd root key is a hard error, not + // an ignored no-op. Without `.strict()` this passed on desktop while Rust + // failed — the exact drift this alignment closes. + const withTypo = { ...manifestJson, faimly_rules: [] }; + assert.throws(() => ManifestSchema.parse(withTypo)); +}); + +test("ManifestSchema rejects an unknown field inside an exact record", () => { + // Strictness must reach nested objects too, not just the root — an exact + // record with a stray key is where a hand-edit typo most plausibly lands. + const mutated = structuredClone(manifestJson); + mutated.exact_records[0].raw_modle_id = "typo"; + assert.throws(() => ManifestSchema.parse(mutated)); +}); diff --git a/desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs b/desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs index ecb36a6fc53..209c3993fd3 100644 --- a/desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs @@ -312,3 +312,130 @@ test("isSuccessfulEmptyDiscovery_stillPending_isFalse", () => { false, ); }); + +// ── Discovered rows resolve through the shared label resolver ──────────────── +// Discovery can return a Databricks endpoint with a null or blank `name` +// (v1 catalogs, and any harness that echoes IDs only). Those rows must still +// show the curated registry name rather than the raw endpoint ID. + +test("discoveredRow_knownDatabricksIdWithNullName_showsCuratedName", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [{ id: "databricks-gpt-5-5", name: null, description: null }], + }), + "", + ); + + assert.deepEqual(options.slice(1), [ + { id: "databricks-gpt-5-5", label: "GPT-5.5" }, + ]); +}); + +test("discoveredRow_knownDatabricksIdWithBlankName_showsCuratedName", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [ + { id: "databricks-claude-opus-4-7", name: " ", description: null }, + ], + }), + "", + ); + + assert.deepEqual(options.slice(1), [ + { id: "databricks-claude-opus-4-7", label: "Claude Opus 4.7" }, + ]); +}); + +test("discoveredRow_unknownCustomEndpointWithNoName_showsRawId", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [ + { id: "databricks-team-2025-01", name: null, description: null }, + ], + }), + "", + ); + + assert.deepEqual(options.slice(1), [ + { id: "databricks-team-2025-01", label: "databricks-team-2025-01" }, + ]); +}); + +test("discoveredRow_nonblankDiscoveredName_winsOverRegistry", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [ + { id: "databricks-gpt-5-5", name: "Workspace GPT", description: null }, + ], + }), + "", + ); + + assert.deepEqual(options.slice(1), [ + { id: "databricks-gpt-5-5", label: "Workspace GPT" }, + ]); +}); + +// ── Real buzz-agent discovery shape: name echoes the id ───────────────────── +// buzz-agent's Databricks discovery emits {id, name: id} on every path (the +// API has no display-name field). The echoed name must not short-circuit the +// registry tier, so a known id still shows its curated label. + +test("discoveredRow_knownDatabricksIdEchoedName_showsCuratedName", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [ + { + id: "databricks-gpt-5-5", + name: "databricks-gpt-5-5", + description: null, + }, + ], + }), + "databricks_v2", + ); + + assert.deepEqual(options.slice(1), [ + { id: "databricks-gpt-5-5", label: "GPT-5.5" }, + ]); +}); + +test("discoveredRow_unknownDatabricksIdEchoedName_showsRawId", () => { + const options = getDiscoveredPersonaModelOptions( + response({ + models: [ + { + id: "databricks-team-2025-01", + name: "databricks-team-2025-01", + description: null, + }, + ], + }), + "databricks_v2", + ); + + assert.deepEqual(options.slice(1), [ + { id: "databricks-team-2025-01", label: "databricks-team-2025-01" }, + ]); +}); + +test("discoveredRow_defaultCatalogSuffixedName_winsOverRegistry", () => { + // The auth-empty fallback carries a distinct curated+suffixed name; tier 1 + // correctly keeps it rather than re-deriving the bare label. + const options = getDiscoveredPersonaModelOptions( + response({ + models: [ + { + id: "databricks-gpt-5-5", + name: "GPT-5.5 (default catalog)", + description: null, + }, + ], + }), + "databricks_v2", + ); + + assert.deepEqual(options.slice(1), [ + { id: "databricks-gpt-5-5", label: "GPT-5.5 (default catalog)" }, + ]); +}); diff --git a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts index e7b434288f3..8f2ef49346a 100644 --- a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts +++ b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts @@ -12,6 +12,7 @@ import { } from "./personaModelDiscoveryStatus"; import type { PersonaModelOption } from "./agentConfigOptions"; import { providerRequiresExplicitModel } from "./agentConfigOptions"; +import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; export const MODEL_DISCOVERY_LOADING_VALUE = "__model_discovery_loading__"; @@ -64,7 +65,7 @@ export function getDiscoveredPersonaModelOptions( provider === "relay-mesh" ? "Default (auto)" : agentDefaultModel - ? `Default model (${agentDefaultModel})` + ? `Default model (${resolveModelLabel(agentDefaultModel, null, provider)})` : "Default model", }, ]; @@ -77,7 +78,7 @@ export function getDiscoveredPersonaModelOptions( ...defaultModelOption, ...explicitModels.map((model) => ({ id: model.id, - label: model.name?.trim() || model.id, + label: resolveModelLabel(model.id, model.name, provider), })), ]; } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index a402285732d..ca732bb3f1f 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -33,6 +33,7 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import { useNow } from "@/shared/lib/useNow"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; +import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; type UserProfilePopoverProps = { children: React.ReactNode; @@ -411,7 +412,13 @@ export function UserProfilePopover({ {runtimeLabel(relayAgent.agentType)} ) : null} {managedAgent?.model ? ( - {managedAgent.model} + + {resolveModelLabel( + managedAgent.model, + null, + managedAgent.provider, + )} + ) : null} {managedAgent?.acpCommand ? ( ACP: {managedAgent.acpCommand} diff --git a/desktop/test-loader-hooks.mjs b/desktop/test-loader-hooks.mjs index 0cc99158acd..ede5cbedae6 100644 --- a/desktop/test-loader-hooks.mjs +++ b/desktop/test-loader-hooks.mjs @@ -89,6 +89,10 @@ export function resolve(specifier, context, nextResolve) { const resolved = path.join(repoRoot, "preview-features.json"); return nextResolve(toFileSpecifier(resolved), context); } + if (specifier === "@model-capabilities-manifest") { + const resolved = path.join(repoRoot, "scripts", "model-capabilities.json"); + return nextResolve(toFileSpecifier(resolved), context); + } if (specifier.startsWith("@/")) { const stripped = specifier.slice(2); // Preserve explicit extensions (.mjs, .js, .json, .ts, etc.). The bundler diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json index 302d8ae922f..a2a57c66efb 100644 --- a/desktop/tsconfig.json +++ b/desktop/tsconfig.json @@ -7,7 +7,8 @@ "skipLibCheck": true, "paths": { "@/*": ["./src/*"], - "@features-manifest": ["../preview-features.json"] + "@features-manifest": ["../preview-features.json"], + "@model-capabilities-manifest": ["../scripts/model-capabilities.json"] }, /* Bundler mode */ diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 1a89ff8d750..5a5de191204 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -25,6 +25,10 @@ export default defineConfig(async () => ({ alias: { "@": "/src", "@features-manifest": path.resolve(__dirname, "../preview-features.json"), + "@model-capabilities-manifest": path.resolve( + __dirname, + "../scripts/model-capabilities.json", + ), }, }, diff --git a/scripts/model-capabilities.json b/scripts/model-capabilities.json new file mode 100644 index 00000000000..2c78867f13b --- /dev/null +++ b/scripts/model-capabilities.json @@ -0,0 +1,1140 @@ +{ + "_comment": "Hand-curated model capability manifest — the single runtime artifact. Consumed directly by Rust (include_str! + serde + OnceLock) and TypeScript (JSON import + zod). No codegen. Exact records are AUTHORITATIVE six-axis snapshots: they do NOT inherit from family rules at runtime. MAINTENANCE RULE: any capability change to a family/prefix rule or a provider fallback must audit every exact_record whose _provenance names that source and update the snapshot (and its corpus expectations) wherever the contract change is intended. Divergence between an exact snapshot and its origin rule is legal and sometimes correct (e.g. provider-advertised effort overrides).", + "_sources": { + "models_dev": "https://models.dev/api.json (retrieved 2026-07-31, SHA-256 d5a4974cd69f19b0f67713acaa6bb3b16e920defdc07ecbdf6b0a936181bb0e0)", + "anthropic_thinking": "https://platform.claude.com/docs/en/build-with-claude/extended-thinking (July 2025)", + "anthropic_effort": "https://platform.claude.com/docs/en/build-with-claude/effort (July 2025)", + "openai_reasoning": "https://platform.openai.com/docs/guides/reasoning (July 2025)" + }, + "family_tokens": [ + "claude-", + "gpt-" + ], + "family_rules": [ + { + "id": "anthropic-manual-budget-claude3", + "match_kind": "prefix", + "match_value": "claude-3", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "manual-budget", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": null, + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-manual-budget-opus-4-5", + "match_kind": "exact", + "match_value": "claude-opus-4-5", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "manual-budget", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": null, + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-adaptive-xhigh-opus-4-7", + "match_kind": "prefix", + "match_value": "claude-opus-4-7", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-adaptive-xhigh-opus-4-8", + "match_kind": "prefix", + "match_value": "claude-opus-4-8", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-adaptive-xhigh-opus-5", + "match_kind": "prefix", + "match_value": "claude-opus-5", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-adaptive-xhigh-sonnet-5", + "match_kind": "prefix", + "match_value": "claude-sonnet-5", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-adaptive-xhigh-fable-5", + "match_kind": "prefix", + "match_value": "claude-fable-5", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-adaptive-xhigh-mythos-5", + "match_kind": "prefix", + "match_value": "claude-mythos-5", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-adaptive-no-xhigh-opus-4-6", + "match_kind": "prefix", + "match_value": "claude-opus-4-6", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-adaptive-no-xhigh-sonnet-4-6", + "match_kind": "prefix", + "match_value": "claude-sonnet-4-6", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "anthropic-adaptive-no-xhigh-mythos-preview", + "match_kind": "prefix", + "match_value": "claude-mythos-preview", + "providers": [ + "anthropic", + "databricks_v2" + ], + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + }, + { + "id": "openai-gpt5-pro", + "match_kind": "prefix", + "match_value": "gpt-5-pro", + "match_aliases": [ + "gpt5-pro" + ], + "providers": [ + "openai", + "databricks", + "databricks_v2" + ], + "thinking_mode": "none", + "supported_efforts": [ + "high" + ], + "default_effort": "high", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard" + }, + { + "id": "openai-gpt5-6", + "match_kind": "prefix", + "match_value": "gpt-5.6", + "match_aliases": [ + "gpt5.6", + "gpt-5-6", + "gpt5-6" + ], + "providers": [ + "openai", + "databricks", + "databricks_v2" + ], + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard" + }, + { + "id": "openai-gpt5-5", + "match_kind": "prefix", + "match_value": "gpt-5.5", + "match_aliases": [ + "gpt5.5", + "gpt-5-5", + "gpt5-5" + ], + "providers": [ + "openai", + "databricks", + "databricks_v2" + ], + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard" + }, + { + "id": "openai-gpt5-4", + "match_kind": "prefix", + "match_value": "gpt-5.4", + "match_aliases": [ + "gpt5.4", + "gpt-5-4", + "gpt5-4" + ], + "providers": [ + "openai", + "databricks", + "databricks_v2" + ], + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard" + }, + { + "id": "openai-gpt5-1", + "match_kind": "prefix", + "match_value": "gpt-5.1", + "match_aliases": [ + "gpt5.1", + "gpt-5-1", + "gpt5-1" + ], + "providers": [ + "openai", + "databricks", + "databricks_v2" + ], + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high" + ], + "default_effort": "none", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard" + }, + { + "id": "openai-gpt5-base", + "match_kind": "prefix", + "match_value": "gpt-5", + "match_aliases": [ + "gpt5" + ], + "providers": [ + "openai", + "databricks", + "databricks_v2" + ], + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard" + }, + { + "id": "dbv2-claude-prefix", + "_comment": "DBv2-only broad Claude prefix. Replaces the routing role of the dropped dbv2-claude-code-names-segment 'claude' token: uncurated databricks-claude-* endpoints still route via Anthropic Messages with conservative (omit-fields) effort classification. Bare code-name segments without a leading 'claude-' (e.g. opus-5, goose-opus-5) are deliberately dropped and fall through to the databricks_v2 concrete-unknown fallback (mlflow-chat) — segment-anywhere matching is not expressible as a prefix.", + "match_kind": "prefix", + "match_value": "claude", + "providers": [ + "databricks_v2" + ], + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none" + } + ], + "_comment_databricks_v2_known_models": "Curated fallback catalog for Databricks v2: the model IDs offered when an authenticated api/ai-gateway/v2/endpoints call succeeds but returns an empty list (see catalog.rs authenticated_empty_v2_catalog). Both IDs also have full exact_records, so once offered they classify precisely. Uniqueness enforced by validate_manifest().", + "databricks_v2_known_models": [ + "databricks-gpt-5-5", + "databricks-claude-opus-4-7" + ], + "exact_records": [ + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-4-mini", + "registry_label": "GPT-5.4 mini", + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "thinking_mode+default_effort+databricks_v2_wire_route+normalization_policy: family:openai-gpt5-4; supported_efforts: curated override", + "source": "models.dev reasoning_options: low|medium|high (family rule adds none+xhigh — adopt provider-advertised)", + "_reconciliation": "adopt", + "_reconciliation_note": "models.dev advertises low|medium|high. Family rule (gpt5-4) adds none+xhigh. Provider-advertised wins per plan F1 policy.", + "_reconciliation_doc": "https://models.dev/api.json (retrieved 2026-07-31, SHA-256 d5a4974cd69f19b0f67713acaa6bb3b16e920defdc07ecbdf6b0a936181bb0e0): providers.databricks.models[\"databricks-gpt-5-4-mini\"].reasoning_options=[{\"type\":\"effort\",\"values\":[\"low\",\"medium\",\"high\"]}]" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-4-nano", + "registry_label": "GPT-5.4 nano", + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "thinking_mode+default_effort+databricks_v2_wire_route+normalization_policy: family:openai-gpt5-4; supported_efforts: curated override", + "source": "models.dev reasoning_options: low|medium|high", + "_reconciliation": "adopt", + "_reconciliation_note": "models.dev advertises low|medium|high. Same as gpt-5-4-mini. Adopt.", + "_reconciliation_doc": "https://models.dev/api.json (retrieved 2026-07-31, SHA-256 d5a4974cd69f19b0f67713acaa6bb3b16e920defdc07ecbdf6b0a936181bb0e0): providers.databricks.models[\"databricks-gpt-5-4-nano\"].reasoning_options=[{\"type\":\"effort\",\"values\":[\"low\",\"medium\",\"high\"]}]" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-6-sol", + "registry_label": "GPT-5.6 Sol", + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "thinking_mode+default_effort+databricks_v2_wire_route+normalization_policy: family:openai-gpt5-6; supported_efforts: curated override", + "source": "models.dev reasoning_options: low|medium|high|max (family rule adds none+xhigh — provider-advertised wins per plan F1)", + "_reconciliation": "adopt", + "_reconciliation_note": "models.dev advertises [low, medium, high, max]. Family rule (gpt5-6) has none+xhigh+max; sol endpoint does not expose none or xhigh. Provider-advertised wins.", + "_reconciliation_doc": "https://models.dev/api.json (retrieved 2026-07-31, SHA-256 d5a4974cd69f19b0f67713acaa6bb3b16e920defdc07ecbdf6b0a936181bb0e0): providers.databricks.models[\"databricks-gpt-5-6-sol\"].reasoning_options=[{\"type\":\"effort\",\"values\":[\"low\",\"medium\",\"high\",\"max\"]}]" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-5", + "registry_label": "GPT-5.5", + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "thinking_mode+default_effort+databricks_v2_wire_route+normalization_policy: family:openai-gpt5-5; supported_efforts: curated override", + "source": "models.dev reasoning_options: low|medium|high (family rule adds none+xhigh — provider-advertised wins per plan F1)", + "_reconciliation": "adopt", + "_reconciliation_note": "models.dev (pinned payload) advertises [low, medium, high]. Family rule (gpt5-5) has none+xhigh; this Databricks endpoint does not expose none or xhigh. Provider-advertised wins.", + "_reconciliation_doc": "https://models.dev/api.json (retrieved 2026-07-31, SHA-256 d5a4974cd69f19b0f67713acaa6bb3b16e920defdc07ecbdf6b0a936181bb0e0): providers.databricks.models[\"databricks-gpt-5-5\"].reasoning_options=[{\"type\":\"effort\",\"values\":[\"low\",\"medium\",\"high\"]}]" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-opus-4-7", + "registry_label": "Claude Opus 4.7", + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "all axes materialized from family:anthropic-adaptive-xhigh-opus-4-7", + "source": "DATABRICKS_V2_KNOWN_MODELS; family rule anthropic-adaptive-xhigh-opus-4-7 applies", + "_reconciliation": "no-effort-divergence", + "_reconciliation_note": "models.dev advertises reasoning_options=[{\"type\":\"budget_tokens\",\"min\":1024}]. This is a different capability axis (extended thinking token budget), not an effort-level selector. No effort divergence to reconcile — efforts for this model come from the anthropic family rule (anthropic-adaptive-xhigh-opus-4-7).", + "_reconciliation_doc": "https://models.dev/api.json (retrieved 2026-07-31, SHA-256 d5a4974cd69f19b0f67713acaa6bb3b16e920defdc07ecbdf6b0a936181bb0e0): providers.databricks.models[\"databricks-claude-opus-4-7\"].reasoning_options=[{\"type\":\"budget_tokens\",\"min\":1024}]" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-6-luna", + "registry_label": "GPT-5.6 Luna", + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "thinking_mode+default_effort+databricks_v2_wire_route+normalization_policy: family:openai-gpt5-6; supported_efforts: curated override", + "source": "models.dev reasoning_options: low|medium|high", + "_reconciliation": "adopt", + "_reconciliation_note": "models.dev advertises [low, medium, high]. Family rule (gpt5-6) has none+xhigh+max; luna endpoint does not expose none, xhigh, or max. Provider-advertised wins.", + "_reconciliation_doc": "https://models.dev/api.json (retrieved 2026-08-04): providers.databricks.models[\"databricks-gpt-5-6-luna\"].reasoning_options=[{\"type\":\"effort\",\"values\":[\"low\",\"medium\",\"high\"]}]" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-6-terra", + "registry_label": "GPT-5.6 Terra", + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "thinking_mode+default_effort+databricks_v2_wire_route+normalization_policy: family:openai-gpt5-6; supported_efforts: curated override", + "source": "models.dev reasoning_options: low|medium|high", + "_reconciliation": "adopt", + "_reconciliation_note": "models.dev advertises [low, medium, high]. Family rule (gpt5-6) has none+xhigh+max; terra endpoint does not expose none, xhigh, or max. Provider-advertised wins.", + "_reconciliation_doc": "https://models.dev/api.json (retrieved 2026-08-04): providers.databricks.models[\"databricks-gpt-5-6-terra\"].reasoning_options=[{\"type\":\"effort\",\"values\":[\"low\",\"medium\",\"high\"]}]" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-haiku-4-5", + "registry_label": "Claude Haiku 4.5 (latest)", + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "all axes materialized from family:dbv2-claude-prefix", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-opus-4-1", + "registry_label": "Claude Opus 4.1 (latest)", + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "all axes materialized from family:dbv2-claude-prefix", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-opus-4-5", + "registry_label": "Claude Opus 4.5 (latest)", + "thinking_mode": "manual-budget", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": null, + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "all axes materialized from family:anthropic-manual-budget-opus-4-5", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-opus-4-6", + "registry_label": "Claude Opus 4.6", + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "all axes materialized from family:anthropic-adaptive-no-xhigh-opus-4-6", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-sonnet-4", + "registry_label": "Claude Sonnet 4.5", + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "all axes materialized from family:dbv2-claude-prefix", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-sonnet-4-5", + "registry_label": "Claude Sonnet 4.5 (latest)", + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "all axes materialized from family:dbv2-claude-prefix", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-sonnet-4-6", + "registry_label": "Claude Sonnet 4.6", + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "all axes materialized from family:anthropic-adaptive-no-xhigh-sonnet-4-6", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gemini-2-5-flash", + "registry_label": "Gemini 2.5 Flash", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gemini-2-5-pro", + "registry_label": "Gemini 2.5 Pro", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gemini-3-1-flash-lite", + "registry_label": "Gemini 3.1 Flash Lite Preview", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gemini-3-1-pro", + "registry_label": "Gemini 3.1 Pro Preview Custom Tools", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gemini-3-flash", + "registry_label": "Gemini 3 Flash Preview", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gemini-3-pro", + "registry_label": "Gemini 3 Pro Preview", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-glm-5-2", + "registry_label": "GLM-5.2", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5", + "registry_label": "GPT-5", + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "all axes materialized from family:openai-gpt5-base", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-1", + "registry_label": "GPT-5.1", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high" + ], + "default_effort": "none", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "all axes materialized from family:openai-gpt5-1", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-2", + "registry_label": "GPT-5.2", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "pinned snapshot preserving the removed dbv2-gpt-code-names-segment outcome (efforts none..xhigh + openai-clamp-max-to-xhigh). Deliberately diverges from the openai-gpt5-base prefix (which yields minimal..high + openai-standard); the divergence is the whole reason this exact record is pinned rather than derived.", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-4", + "registry_label": "GPT-5.4", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "all axes materialized from family:openai-gpt5-4", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-mini", + "registry_label": "GPT-5 Mini", + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "all axes materialized from family:openai-gpt5-base", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-nano", + "registry_label": "GPT-5 Nano", + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "_provenance": "all axes materialized from family:openai-gpt5-base", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-oss-120b", + "registry_label": "GPT OSS 120B", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-oss-20b", + "registry_label": "GPT OSS 20B", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-kimi-k2-7-code", + "registry_label": "Kimi K2.7 Code", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "_provenance": "all axes materialized from provider fallback (databricks_v2/concrete_unknown)", + "_source": "registry_labels" + } + ], + "provider_fallbacks": { + "anthropic": { + "blank": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "normalization_policy": "none" + }, + "concrete_unknown": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "normalization_policy": "none" + } + }, + "openai": { + "blank": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "normalization_policy": "openai-clamp-max-to-xhigh" + }, + "concrete_unknown": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "normalization_policy": "openai-clamp-max-to-xhigh" + } + }, + "databricks_v2": { + "blank": { + "databricks_v2_wire_route": "route-unknown", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "normalization_policy": "openai-clamp-max-to-xhigh" + }, + "concrete_unknown": { + "databricks_v2_wire_route": "mlflow-chat", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "normalization_policy": "openai-clamp-max-to-xhigh" + } + }, + "databricks": { + "blank": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "normalization_policy": "openai-clamp-max-to-xhigh" + }, + "concrete_unknown": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "normalization_policy": "openai-clamp-max-to-xhigh" + } + }, + "openrouter": { + "blank": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "normalization_policy": "none" + }, + "concrete_unknown": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "normalization_policy": "none" + } + }, + "_default": { + "blank": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "normalization_policy": "none" + }, + "concrete_unknown": { + "databricks_v2_wire_route": "not-applicable", + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "normalization_policy": "none" + } + } + } +} diff --git a/scripts/normative-corpus.json b/scripts/normative-corpus.json new file mode 100644 index 00000000000..2543fd6cbe6 --- /dev/null +++ b/scripts/normative-corpus.json @@ -0,0 +1,2074 @@ +[ + { + "_group": "Anthropic curated family-rule model names" + }, + { + "id": "anthropic-claude-3-family", + "provider": "anthropic", + "raw_model_id": "claude-3-7-sonnet-20250219", + "expect": { + "thinking_mode": "manual-budget", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": null, + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-claude-opus-4-5", + "provider": "anthropic", + "raw_model_id": "claude-opus-4-5", + "expect": { + "thinking_mode": "manual-budget", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": null, + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-claude-opus-4-7", + "provider": "anthropic", + "raw_model_id": "claude-opus-4-7", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-claude-opus-4-8", + "provider": "anthropic", + "raw_model_id": "claude-opus-4-8", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-claude-sonnet-5", + "provider": "anthropic", + "raw_model_id": "claude-sonnet-5-20260101", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-claude-fable-5", + "provider": "anthropic", + "raw_model_id": "claude-fable-5", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-claude-mythos-5", + "provider": "anthropic", + "raw_model_id": "claude-mythos-5", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-claude-opus-4-6", + "provider": "anthropic", + "raw_model_id": "claude-opus-4-6", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-claude-sonnet-4-6", + "provider": "anthropic", + "raw_model_id": "claude-sonnet-4-6", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-claude-mythos-preview", + "provider": "anthropic", + "raw_model_id": "claude-mythos-preview", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "_group": "Anthropic blank and concrete-unknown inputs" + }, + { + "id": "anthropic-unknown-blank", + "provider": "anthropic", + "raw_model_id": "", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-unknown-concrete", + "provider": "anthropic", + "raw_model_id": "claude-ultra-9000", + "expect": { + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "_group": "OpenAI curated family-rule model names" + }, + { + "id": "openai-gpt5-pro", + "provider": "openai", + "raw_model_id": "gpt-5-pro", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "high" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5.6", + "provider": "openai", + "raw_model_id": "gpt-5.6", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5-6-dashed", + "provider": "openai", + "raw_model_id": "gpt-5-6", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5.5", + "provider": "openai", + "raw_model_id": "gpt-5.5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5.4", + "provider": "openai", + "raw_model_id": "gpt-5.4", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5.1", + "provider": "openai", + "raw_model_id": "gpt-5.1", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high" + ], + "default_effort": "none", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5-base", + "provider": "openai", + "raw_model_id": "gpt-5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "_group": "OpenAI gpt-5 boundary-matching probes (ported from config.rs tests)" + }, + { + "id": "openai-gpt5-1106-date-suffix-probe", + "provider": "openai", + "raw_model_id": "gpt-5-1106", + "_note": "Probes a 4-digit date-shaped suffix after the gpt-5 stem.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5-4o-alpha-suffix-probe", + "provider": "openai", + "raw_model_id": "gpt-5-4o", + "_note": "Probes a leading-digit-then-letter suffix ('4o') after the gpt-5 stem.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5-pro-precedence-probe", + "provider": "openai", + "raw_model_id": "gpt-5-pro", + "_note": "Probes precedence between the gpt-5-pro rule and the gpt-5 base stem.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "high" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5-10-multi-digit-probe", + "provider": "openai", + "raw_model_id": "gpt-5-10", + "_note": "Probes a two-digit minor-version suffix after the gpt-5 stem.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5-date-suffix-probe", + "provider": "openai", + "raw_model_id": "gpt-5-20260101", + "_note": "Probes an 8-digit date suffix after the gpt-5 stem.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "_group": "DatabricksV2 segment/prefix routing probes (ported from llm.rs tests)" + }, + { + "id": "dbv2-gpt5-5-probe", + "provider": "databricks_v2", + "raw_model_id": "gpt-5.5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "dbv2-claude-opus-4-7-probe", + "provider": "databricks_v2", + "raw_model_id": "claude-opus-4-7", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "dbv2-databricks-prefix-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-opus-4-7", + "_note": "Probes stripping of the databricks- catalog prefix.", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "registry_label": "Claude Opus 4.7" + } + }, + { + "id": "dbv2-goose-claude-prefix-probe", + "provider": "databricks_v2", + "raw_model_id": "goose-claude-fable-5", + "_note": "Probes stripping of the goose- catalog prefix.", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "dbv2-team-prefix-probe", + "provider": "databricks_v2", + "raw_model_id": "team-x-claude-opus-4-7", + "_note": "Probes stripping of a team-x- catalog prefix.", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "dbv2-consolidated-llama-substring-probe", + "provider": "databricks_v2", + "raw_model_id": "consolidated-llama", + "_note": "Probes a name where a code word ('sol') appears only as a substring, not a boundary-aligned segment.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-terraform-coder-substring-probe", + "provider": "databricks_v2", + "raw_model_id": "terraform-coder", + "_note": "Probes a name where a code word ('terra') is only a segment prefix, not a full segment.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-corpus-reranker-substring-probe", + "provider": "databricks_v2", + "raw_model_id": "corpus-reranker", + "_note": "Probes a name where 'opus' appears only as a substring of a segment.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-octopus-model-substring-probe", + "provider": "databricks_v2", + "raw_model_id": "octopus-model", + "_note": "Probes a name where 'opus' appears only as a substring of a segment.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-goose-opus-5-prefix-probe", + "provider": "databricks_v2", + "raw_model_id": "goose-opus-5", + "_note": "Probes a goose- prefix over a bare code-name segment with no leading claude.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "_group": "Resolver-contract probes (plan v4 §Resolver contract)" + }, + { + "id": "resolver-exact-raw-id-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-4-mini", + "_note": "Probes a raw id that has an exact record.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5.4 mini" + } + }, + { + "id": "resolver-prefixed-alias-probe", + "provider": "databricks_v2", + "raw_model_id": "team-x-databricks-gpt-5-4-mini", + "_note": "Probes a prefixed alias of an exact-record id (raw exact key differs).", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "resolver-cross-provider-probe", + "provider": "openai", + "raw_model_id": "databricks-gpt-5-4-mini", + "_note": "Probes the same raw id under a different provider (exact records are provider-scoped).", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "resolver-exact-record-with-family-route-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-6-sol", + "_note": "Exact-vs-family route-axis probe (raw exact key with a covering family rule).", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5.6 Sol" + } + }, + { + "id": "dbv2-gpt5-5-exact-record-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-5", + "_note": "Exact-vs-family effort-axis probe (exact record overlapping a family rule).", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5.5" + } + }, + { + "_group": "Blank and concrete-unknown inputs per provider" + }, + { + "id": "dbv2-blank-probe", + "provider": "databricks_v2", + "raw_model_id": "", + "_note": "Probes a blank databricks_v2 model id.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "route-unknown", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-concrete-unknown-probe", + "provider": "databricks_v2", + "raw_model_id": "some-unknown-model-xyz", + "_note": "Probes a concrete, uncatalogued databricks_v2 model id.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "openai-blank-probe", + "provider": "openai", + "raw_model_id": "", + "_note": "Probes a blank openai model id.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "openai-concrete-unknown-probe", + "provider": "openai", + "raw_model_id": "gpt-4o", + "_note": "Probes a concrete openai model id in no verified family.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "anthropic-blank-probe", + "provider": "anthropic", + "raw_model_id": "", + "_note": "Probes a blank anthropic model id.", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "anthropic-concrete-unknown-probe", + "provider": "anthropic", + "raw_model_id": "claude-ultra-9000", + "_note": "Probes a concrete, uncatalogued anthropic model id.", + "expect": { + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "_group": "Legacy Databricks provider inputs" + }, + { + "id": "databricks-gpt5-pro-probe", + "provider": "databricks", + "raw_model_id": "databricks-gpt-5-pro", + "_note": "Probes the legacy databricks provider with a GPT-5 Pro id.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "high" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "databricks-gpt5-6-probe", + "provider": "databricks", + "raw_model_id": "databricks-gpt-5.6", + "_note": "Probes the legacy databricks provider with a GPT-5.6 id.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "databricks-gpt5-1-probe", + "provider": "databricks", + "raw_model_id": "databricks-gpt-5.1", + "_note": "Probes the legacy databricks provider with a GPT-5.1 id.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high" + ], + "default_effort": "none", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "_group": "openai-compat alias canonicalization probes", + "_note": "Probes whether openai-compat is canonicalized to openai before resolving; both interpreters must agree." + }, + { + "id": "openai-compat-gpt-5-pro-probe", + "provider": "openai-compat", + "raw_model_id": "gpt-5-pro", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "high" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-compat-gpt-5-5-probe", + "provider": "openai-compat", + "raw_model_id": "gpt-5.5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-compat-blank-probe", + "provider": "openai-compat", + "raw_model_id": "", + "_note": "Probes openai-compat canonicalization with a blank model id.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "_group": "gpt-5 short-version-suffix boundary probes (Rust/TS divergence window)", + "_note": "Probes the 1-2 digit version-suffix window where the Rust guard and the TS regex historically diverged." + }, + { + "id": "openai-gpt5-10-preview-probe", + "provider": "openai", + "raw_model_id": "gpt-5-10-preview", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5-2-mini-probe", + "provider": "openai", + "raw_model_id": "gpt-5-2-mini", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "openai-gpt5-9-dot-1-probe", + "provider": "openai", + "raw_model_id": "gpt-5-9.1", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "dbv2-gpt5-10-multi-axis-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-10", + "_note": "Probes a databricks_v2 gpt-5- id, exercising both the effort axes and the wire route.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "dbv2-gpt-5-2-exact-vs-base-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-2", + "_note": "Exact-vs-base-stem probe: an exact record coexisting with the gpt-5 base stem rule.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": "GPT-5.2" + } + }, + { + "id": "openai-customgpt-5-5-nonboundary-probe", + "provider": "openai", + "raw_model_id": "customgpt-5-5-endpoint", + "_note": "Probes a name whose gpt- token is not boundary-aligned (preceded by 'm' in customgpt).", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "_group": "DBv2 gpt-segment boundary probes", + "_note": "Probes whether 'gpt' is treated as a full segment rather than a segment prefix." + }, + { + "id": "dbv2-gptoss-segment-probe", + "provider": "databricks_v2", + "raw_model_id": "gptoss-model", + "_note": "Probes a segment ('gptoss') that starts with but is not exactly 'gpt'/'gpt5'.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-gptj-6b-segment-probe", + "provider": "databricks_v2", + "raw_model_id": "gptj-6b", + "_note": "Probes a segment ('gptj') that is not exactly 'gpt'/'gpt5'.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-customgpt-nonboundary-probe", + "provider": "databricks_v2", + "raw_model_id": "customgpt-5-5-endpoint", + "_note": "Probes a name whose gpt- token is not boundary-aligned (preceded by 'm' in customgpt).", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-gpt-neox-version-segment-probe", + "provider": "databricks_v2", + "raw_model_id": "gpt-neox-20b", + "_note": "Probes a gpt- name whose next segment ('neox') is non-numeric.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-gpt5-custom-segment-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt5-custom", + "_note": "Probes a 'gpt5' segment inside a databricks- prefixed name.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-gpt-opus-5-dual-marker-probe", + "provider": "databricks_v2", + "raw_model_id": "gpt-opus-5", + "_note": "Probes a name carrying both a gpt marker and a claude code word.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "_group": "Additional coverage probes" + }, + { + "id": "anthropic-opus-5-prefix-probe", + "provider": "anthropic", + "raw_model_id": "claude-opus-5-20270101", + "_note": "Probes the claude-opus-5 prefix rule.", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "dbv2-gpt-5-6-sol-normalization-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-6-sol", + "_note": "Probes the sol exact record's normalization and effort axes.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5.6 Sol" + } + }, + { + "id": "dbv2-gpt-5-6-luna-exact-record-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-6-luna", + "_note": "Probes the luna exact record against its family rule.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5.6 Luna" + } + }, + { + "id": "dbv2-gpt-5-6-terra-exact-record-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-6-terra", + "_note": "Probes the terra exact record against its family rule.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5.6 Terra" + } + }, + { + "id": "dbv2-gpt-5-4-nano-exact-record-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-4-nano", + "_note": "Probes the gpt-5-4-nano exact record and its label.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5.4 nano" + } + }, + { + "id": "openrouter-concrete-unknown-probe", + "provider": "openrouter", + "raw_model_id": "some-model-xyz", + "_note": "Probes an uncatalogued openrouter model id.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "openai-gpt5-pro-uppercase-provider-probe", + "provider": "OpenAI", + "raw_model_id": "gpt-5-pro", + "_note": "Probes an uppercased provider string ('OpenAI').", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "high" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "dbv2-uppercase-model-probe", + "provider": "databricks_v2", + "raw_model_id": "DATABRICKS-GPT-5-4-NANO", + "_note": "Probes an uppercased raw model id against a lowercase exact record.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5.4 nano" + } + }, + { + "_group": "Prototype-key provider probes", + "_note": "Probes provider strings that collide with Object prototype keys." + }, + { + "id": "prototype-key-constructor-blank-probe", + "provider": "constructor", + "raw_model_id": "", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "prototype-key-constructor-some-model-probe", + "provider": "constructor", + "raw_model_id": "some-model", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "prototype-key-proto__-blank-probe", + "provider": "__proto__", + "raw_model_id": "", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "prototype-key-proto__-some-model-probe", + "provider": "__proto__", + "raw_model_id": "some-model", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "_group": "Non-boundary gpt- prefix probes", + "_note": "Probes names whose gpt- token is not boundary-aligned (preceded by an alphanumeric)." + }, + { + "id": "openai-sgpt-5-5-nonboundary-probe", + "provider": "openai", + "raw_model_id": "sgpt-5-5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-sgpt-5-5-nonboundary-probe", + "provider": "databricks_v2", + "raw_model_id": "sgpt-5-5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "openai-mygpt-5-nonboundary-probe", + "provider": "openai", + "raw_model_id": "mygpt-5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-mygpt-5-nonboundary-probe", + "provider": "databricks_v2", + "raw_model_id": "mygpt-5", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "dbv2-gpt-5-mini-exact-record-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-mini", + "_note": "Probes the gpt-5-mini exact record and its label.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5 Mini" + } + }, + { + "id": "dbv2-gpt-5-nano-exact-record-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-5-nano", + "_note": "Probes the gpt-5-nano exact record and its label.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "openai-responses", + "normalization_policy": "openai-standard", + "registry_label": "GPT-5 Nano" + } + }, + { + "id": "dbv2-claude-opus-5-custom-family-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-opus-5-custom", + "_note": "Probes a family-matched name with no exact record and its label axis.", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "dbv2-gpt-doubled-separator-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt--5", + "_note": "Probes a doubled separator between gpt and its version.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "_group": "gpt-5 prefix collision probes (longest-prefix + boundary)" + }, + { + "id": "collision-gpt-5-base-probe", + "provider": "openai", + "raw_model_id": "gpt-5", + "_note": "Probes the base gpt-5 stem alone.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "collision-gpt-5-pro-probe", + "provider": "openai", + "raw_model_id": "gpt-5-pro", + "_note": "Probes gpt-5-pro against the shorter gpt-5 stem.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "high" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "collision-gpt-5-10-probe", + "provider": "openai", + "raw_model_id": "gpt-5-10", + "_note": "Probes a two-digit minor version against the gpt-5 stem.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "collision-gpt-5-6-probe", + "provider": "openai", + "raw_model_id": "gpt-5.6", + "_note": "Probes a dotted minor version against the gpt-5 stem.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "collision-gpt-5-1-probe", + "provider": "openai", + "raw_model_id": "gpt-5.1", + "_note": "Probes the gpt-5.1 prefix.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high" + ], + "default_effort": "none", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "_group": "Uncurated DBv2 token probes" + }, + { + "id": "uncurated-dbv2-gpt-6-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-6", + "_note": "Probes a non-5 gpt version with no exact record or prefix rule.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "uncurated-dbv2-gpt-4o-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-gpt-4o", + "_note": "Probes an uncatalogued gpt-4o databricks_v2 id.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "uncurated-dbv2-opus-5-bare-probe", + "provider": "databricks_v2", + "raw_model_id": "opus-5", + "_note": "Probes a bare Claude code-name segment with no leading claude.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "uncurated-dbv2-sol-bare-probe", + "provider": "databricks_v2", + "raw_model_id": "sol", + "_note": "Probes a bare OpenAI code name.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "uncurated-dbv2-claude-prefix-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-experimental", + "_note": "Probes an uncurated databricks-claude-* name.", + "expect": { + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "_group": "Negative-match probes (no family rule expected to bind)" + }, + { + "id": "neg-gptoss-openai-probe", + "provider": "openai", + "raw_model_id": "gptoss", + "_note": "Probes a name with no gpt- boundary token.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "neg-gptj-6b-openai-probe", + "provider": "openai", + "raw_model_id": "gptj-6b", + "_note": "Probes 'gptj', which is not a gpt- token.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "neg-consolidated-llama-dbv2-probe", + "provider": "databricks_v2", + "raw_model_id": "consolidated-llama", + "_note": "Probes a name where 'sol' is a substring, not a segment.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "neg-terraform-coder-dbv2-probe", + "provider": "databricks_v2", + "raw_model_id": "terraform-coder", + "_note": "Probes a name where 'terra' is a substring, not a segment.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "neg-octopus-model-dbv2-probe", + "provider": "databricks_v2", + "raw_model_id": "octopus-model", + "_note": "Probes a name where 'opus' is a substring, not a leading claude prefix.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "mlflow-chat", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "_group": "Exact+prefix matcher boundary probes" + }, + { + "id": "boundary-embedded-token-openai-probe", + "provider": "openai", + "raw_model_id": "gpt-4-gpt-5-pro", + "_note": "Probes a gpt-5-pro token embedded mid-name rather than at the start.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-clamp-max-to-xhigh", + "registry_label": null + } + }, + { + "id": "boundary-dot-suffix-openai-probe", + "provider": "openai", + "raw_model_id": "gpt-5.6.x", + "_note": "Probes a trailing dot-delimited segment after gpt-5.6.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + }, + { + "id": "boundary-claude-3-digit-run-anthropic-probe", + "provider": "anthropic", + "raw_model_id": "claude-35", + "_note": "Probes whether the claude-3 prefix binds a longer digit run ('35').", + "expect": { + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "boundary-claude-opus-4-70-anthropic-probe", + "provider": "anthropic", + "raw_model_id": "claude-opus-4-70", + "_note": "Probes whether the claude-opus-4-7 prefix binds a longer digit run ('70').", + "expect": { + "thinking_mode": "omit-fields", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "none", + "registry_label": null + } + }, + { + "id": "boundary-gpt-5-1234-openai-probe", + "provider": "openai", + "raw_model_id": "gpt-5-1234", + "_note": "Probes a 4-digit run after the gpt-5 stem.", + "expect": { + "thinking_mode": "none", + "supported_efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "default_effort": "medium", + "databricks_v2_wire_route": "not-applicable", + "normalization_policy": "openai-standard", + "registry_label": null + } + } +] diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 0e5946ca186..0bbdfca6a4d 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -112,6 +112,14 @@ run_unit_tests() { # the two lists must stay in step or the fallback silently covers less. run_test_step "buzz-backend-kubernetes tests" \ cargo test -p buzz-backend-kubernetes -- --nocapture + + # buzz-agent model-capabilities corpus: the Rust half of the cross-language + # drift guard. model_capabilities.rs embeds scripts/model-capabilities.json + + # scripts/normative-corpus.json via include_str! and replays all 103 vectors + # as pure in-process tests (no infra). Mirrors the nextest path in + # `just test-unit` — the two lists must stay in step. + run_test_step "buzz-agent unit tests" \ + cargo test -p buzz-agent --lib -- --nocapture } # ---- DB / integration tests (infra required) -------------------------------- From f716eef437dcf91994518b8df7f581e86bb51748 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 10:51:00 -0600 Subject: [PATCH 02/16] fix(desktop): enforce shared agent access across devices (#6086) ## Summary - discover shared managed agents from authenticated relay directory records instead of treating channel membership as sufficient proof - publish and refresh access-policy changes immediately so running clients converge across machines without a restart or five-minute poll - route profile edits through the exact managed instance and stop/restart runtimes around access changes so unrelated edits cannot silently widen access - keep mention send-time revalidation and Block owner-only build enforcement fail closed - explain invalid custom provider/model configuration instead of leaving Save silently disabled ### Related issue Fixes #3204 ### Known residuals - a brand-new remote agent's first policy record can wait for the bounded directory poll when no authenticated directory coordinate exists yet; send-time mention revalidation remains fail closed - a failed remote-provider policy redeploy is recorded but cannot undeploy the older provider instance until the provider protocol gains the destructor tracked by #5570 ### Testing - full Desktop unit suite: 4,961 tests passed - focused profile editor Playwright workflow passed, including Customize access edits and prompt-only edits after tightening an instance - Desktop TypeScript, Biome formatting, file-size ratchet, Tauri checks, and pre-push suites passed - independently reviewed for authenticated directory trust, live subscription teardown, runtime revocation ordering, fail-open edit paths, and per-agent provider deployment serialization --------- Signed-off-by: Wes Signed-off-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Co-authored-by: diegorumo Co-authored-by: Carl Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/app_state.rs | 6 +- .../src-tauri/src/commands/agent_access.rs | 13 + .../src/commands/agent_config_tests.rs | 1 + .../src-tauri/src/commands/agent_discovery.rs | 55 +- .../agent_discovery/relay_directory.rs | 426 ++++++++++ .../src-tauri/src/commands/agent_models.rs | 219 +---- .../src/commands/agent_models_tests.rs | 27 + .../src/commands/agent_models_update.rs | 360 +++++++++ .../src/commands/agent_models_update_tests.rs | 31 + .../src/commands/agent_update_rollback.rs | 42 +- desktop/src-tauri/src/commands/agents.rs | 95 +-- .../src/commands/agents/provider_access.rs | 72 +- .../src/commands/agents/provider_deploy.rs | 211 +++++ .../src-tauri/src/commands/agents_deploy.rs | 2 +- .../src-tauri/src/commands/agents_tests.rs | 6 + .../commands/personas/delete_cascade_tests.rs | 1 + .../src/commands/personas/inbound.rs | 203 ++++- .../personas/inbound/inbound_tests.rs | 4 +- .../personas/snapshot/fidelity_tests.rs | 1 + .../src/commands/personas/snapshot/import.rs | 1 + .../src/commands/personas/snapshot/tests.rs | 1 + .../personas/update/name_propagation_tests.rs | 1 + .../src-tauri/src/commands/team_snapshot.rs | 1 + .../src/commands/team_snapshot/tests.rs | 1 + desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/main.rs | 4 + .../src/managed_agents/agent_events.rs | 1 + .../managed_agents/agent_snapshot_envelope.rs | 1 + .../managed_agents/agent_snapshot_tests.rs | 1 + .../config_bridge/reader_tests.rs | 1 + .../src/managed_agents/discovery/tests.rs | 3 +- .../managed_agents/effective_config/tests.rs | 1 + .../src/managed_agents/global_config/tests.rs | 1 + .../src/managed_agents/nest/tests.rs | 1 + .../src/managed_agents/parallelism.rs | 1 + .../src/managed_agents/persona_events.rs | 17 +- .../managed_agents/persona_events/tests.rs | 1 + .../src-tauri/src/managed_agents/readiness.rs | 3 +- .../src-tauri/src/managed_agents/retention.rs | 58 +- .../managed_agents/runtime/test_fixtures.rs | 1 + .../managed_agents/spawn_snapshot/tests.rs | 1 + .../src/managed_agents/team_snapshot.rs | 1 + .../src/managed_agents/teams_tests.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 15 +- .../src/managed_agents/types/tests.rs | 15 + desktop/src-tauri/src/nostr_convert.rs | 441 +--------- .../src/nostr_convert/agent_directory.rs | 191 +++++ desktop/src-tauri/src/nostr_convert/tests.rs | 762 ++++++++++++++++++ desktop/src/features/agents/AGENTS.md | 8 +- desktop/src/features/agents/hooks.ts | 12 +- .../agents/lib/pickProfileAgent.test.mjs | 59 +- .../features/agents/lib/pickProfileAgent.ts | 20 + .../agents/lib/useAgentsDataRefresh.test.mjs | 101 +++ .../agents/lib/useAgentsDataRefresh.ts | 154 +++- .../agents/lib/usePersonaSync.test.mjs | 43 + .../src/features/agents/lib/usePersonaSync.ts | 14 +- .../agents/ui/AgentDefinitionDialog.tsx | 7 - .../agents/ui/AgentDefinitionDialogFooter.tsx | 10 - .../ui/agentAiConfigurationPolicy.test.mjs | 33 + .../agents/ui/agentAiConfigurationPolicy.ts | 15 + .../agents/ui/agentProfileSyncWarning.ts | 2 +- .../agents/ui/personaDialogState.test.mjs | 30 + .../features/agents/ui/personaDialogState.ts | 10 +- .../channels/ui/EditRespondToDialog.tsx | 4 +- .../channels/ui/MembersSidebarMemberCard.tsx | 55 +- .../lib/useCanonicalManagedAgentProfile.ts | 28 +- .../features/profile/ui/UserProfilePanel.tsx | 25 +- .../profile/ui/UserProfilePanelUtils.test.mjs | 38 + .../profile/ui/UserProfilePanelUtils.ts | 16 + desktop/src/features/pulse/ui/PulseView.tsx | 1 + desktop/src/shared/api/tauri.ts | 4 +- desktop/src/shared/api/types.ts | 2 +- .../tests/e2e/agent-access-warning.spec.ts | 154 +++- 73 files changed, 3214 insertions(+), 935 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs create mode 100644 desktop/src-tauri/src/commands/agent_models_update.rs create mode 100644 desktop/src-tauri/src/commands/agent_models_update_tests.rs create mode 100644 desktop/src-tauri/src/commands/agents/provider_deploy.rs create mode 100644 desktop/src-tauri/src/nostr_convert/agent_directory.rs create mode 100644 desktop/src-tauri/src/nostr_convert/tests.rs create mode 100644 desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index fc90e6ab14a..d1784c01fa4 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -39,9 +39,7 @@ pub struct AppState { /// restore. `apply_workspace` consumes it after installing the workspace /// relay and identity, so agents never start against the fallback relay. pub managed_agent_restore_pending: AtomicBool, - /// Whether desktop may repair managed-agent kind:0 profiles from its local - /// records. Disabled by the agent-managed profiles experiment so an agent's - /// own profile updates are not overwritten on start or restore. + /// Disabled by agent-managed profiles so agent profile updates survive start/restore. pub managed_agent_profile_reconcile_enabled: AtomicBool, /// Shared shutdown signal checked by launch-time agent restoration. pub shutdown_started: AtomicBool, @@ -52,6 +50,7 @@ pub struct AppState { pub managed_agents_store_lock: Mutex<()>, pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, + pub provider_deploy_locks: Mutex>>>, pub huddle_state: Mutex, pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState, /// Tauri app handle — stored after setup so huddle commands can emit @@ -215,6 +214,7 @@ pub fn build_app_state() -> AppState { managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), + provider_deploy_locks: Mutex::new(HashMap::new()), session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), huddle_audio: Default::default(), diff --git a/desktop/src-tauri/src/commands/agent_access.rs b/desktop/src-tauri/src/commands/agent_access.rs index ef118e82b20..f2851626a85 100644 --- a/desktop/src-tauri/src/commands/agent_access.rs +++ b/desktop/src-tauri/src/commands/agent_access.rs @@ -4,6 +4,19 @@ pub fn agent_access_owner_only() -> bool { crate::managed_agents::owner_only_access_build() } +/// Tiny executable-facing probe for release packaging smoke tests. Keeping the +/// probe in the product crate makes it impossible for buzz-releases to validate +/// a copied flag interpretation that has drifted from Desktop's command. +#[doc(hidden)] +pub fn print_agent_access_owner_only_probe_if_requested() -> bool { + if std::env::args().any(|arg| arg == "--print-agent-access-owner-only") { + println!("{}", agent_access_owner_only()); + true + } else { + false + } +} + #[cfg(test)] mod tests { #[test] diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index b63370b95f8..77e43f5d646 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -89,6 +89,7 @@ fn agent_record() -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9609db5f2df..e8e910b6451 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,14 +1,7 @@ -use tauri::State; - -use crate::{ - app_state::AppState, - managed_agents::{ - command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, - RelayAgentInfo, DEFAULT_ACP_COMMAND, - }, - nostr_convert, - relay::query_relay, +use crate::managed_agents::{ + command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, + DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, + DEFAULT_ACP_COMMAND, }; mod post_install_verification; @@ -1037,31 +1030,31 @@ pub async fn discover_managed_agent_prereqs( .map_err(|e| format!("spawn_blocking failed: {e}")) } -#[tauri::command] -pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { - // Query kind:10100 agent profile events from the relay. - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [10100], - })], - ) - .await?; - - // The convert helper returns `{"agents": [...]}`. Extract and re-deserialize - // into the strongly-typed `Vec` the frontend expects. - let value = nostr_convert::agents_from_events(&events); - let agents = value - .get("agents") - .cloned() - .unwrap_or_else(|| serde_json::json!([])); - serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}")) -} +mod relay_directory; +#[cfg(test)] +use relay_directory::advance_relay_cursor; +pub use relay_directory::list_relay_agents; #[cfg(test)] mod tests { use super::*; + #[test] + fn relay_directory_cursor_uses_timestamp_and_event_id() { + use nostr::{EventBuilder, Keys, Kind, Timestamp}; + + let event = EventBuilder::new(Kind::Custom(30177), "{}") + .custom_created_at(Timestamp::from(42)) + .sign_with_keys(&Keys::generate()) + .expect("sign cursor event"); + let mut filter = serde_json::json!({"kinds": [30177]}); + + advance_relay_cursor(&mut filter, std::slice::from_ref(&event)); + + assert_eq!(filter["until"], 42); + assert_eq!(filter["before_id"], event.id.to_hex()); + } + // ── is_npm_global_install ───────────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs new file mode 100644 index 00000000000..b97cefc0ee8 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -0,0 +1,426 @@ +//! Relay-backed shared-agent directory discovery. + +use tauri::State; + +use crate::{ + app_state::AppState, commands::identity_archive, managed_agents::RelayAgentInfo, nostr_convert, + relay::query_relay, +}; + +const RELAY_DIRECTORY_PAGE_SIZE: usize = 500; +const RELAY_FILTER_BATCH_SIZE: usize = 10; + +fn exact_author_filters(pubkeys: &[String], kind: u16) -> Vec { + pubkeys + .iter() + .map(|pubkey| { + serde_json::json!({ + "authors": [pubkey], + "kinds": [kind], + "limit": 1, + }) + }) + .collect() +} + +fn managed_policy_filters( + candidate_pubkeys: &[String], + verified_owners: &std::collections::HashMap, +) -> Vec { + candidate_pubkeys + .iter() + .filter_map(|agent_pubkey| { + verified_owners.get(agent_pubkey).map(|owner_pubkey| { + serde_json::json!({ + "authors": [owner_pubkey], + "kinds": [30177], + "#d": [agent_pubkey], + "limit": 1, + }) + }) + }) + .collect() +} + +fn current_user_pubkey(state: &AppState) -> Result { + state + .keys + .lock() + .map(|keys| keys.public_key().to_hex()) + .map_err(|error| error.to_string()) +} + +pub(super) fn advance_relay_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) { + let last = page + .last() + .expect("a full relay page always has a last event"); + filter["until"] = serde_json::json!(last.created_at.as_secs()); + filter["before_id"] = serde_json::json!(last.id.to_hex()); +} + +async fn query_all_relay_pages( + state: &AppState, + mut filter: serde_json::Value, +) -> Result, String> { + filter["limit"] = serde_json::json!(RELAY_DIRECTORY_PAGE_SIZE); + let mut events = Vec::new(); + loop { + let page = query_relay(state, &[filter.clone()]).await?; + let done = page.len() < RELAY_DIRECTORY_PAGE_SIZE; + if !done { + advance_relay_cursor(&mut filter, &page); + } + events.extend(page); + if done { + return Ok(events); + } + } +} + +pub(crate) async fn list_relay_agents_for_state( + state: &AppState, +) -> Result, String> { + let viewer_pubkey = current_user_pubkey(state)?; + let relay_pubkey = identity_archive::fetch_relay_self(state) + .await? + .ok_or_else(|| "relay agent membership authority is unavailable".to_string())?; + + // Membership is the authoritative and bounded candidate source. Only + // channels visible to this identity are read, and only bot-role p-tags can + // drive the downstream managed-policy and owner-profile lookups. + let membership_events = query_all_relay_pages( + state, + serde_json::json!({ + "kinds": [39002], + "authors": [&relay_pubkey], + "#p": [&viewer_pubkey], + }), + ) + .await + .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; + let member_agent_channel_ids = + nostr_convert::member_agent_channel_ids_from_events(&membership_events, &relay_pubkey); + let candidate_pubkeys: Vec = member_agent_channel_ids.keys().cloned().collect(); + if candidate_pubkeys.is_empty() { + return Ok(Vec::new()); + } + + let mut directory_events = Vec::new(); + let mut profile_events = Vec::new(); + let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); + let profile_filters = exact_author_filters(&candidate_pubkeys, 0); + for filter_offset in (0..candidate_pubkeys.len()).step_by(RELAY_FILTER_BATCH_SIZE) { + let filter_end = (filter_offset + RELAY_FILTER_BATCH_SIZE).min(candidate_pubkeys.len()); + let (directory, profiles) = tokio::join!( + query_relay(state, &directory_filters[filter_offset..filter_end]), + query_relay(state, &profile_filters[filter_offset..filter_end]), + ); + directory_events.extend( + directory + .map_err(|error| format!("relay agent runtime-directory query failed: {error}"))?, + ); + profile_events.extend( + profiles.map_err(|error| format!("relay agent owner-profile query failed: {error}"))?, + ); + } + + // Only the agent's signed NIP-OA profile can name the owner coordinate to + // query. Each exact `(owner, d=agent)` filter returns at most one current + // replaceable event, so forged 30177 coordinates cannot amplify or crowd + // the authentic policy out of a bounded result page. + let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); + let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners); + let mut managed_agent_events = Vec::new(); + for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) { + managed_agent_events.extend( + query_relay(state, filters) + .await + .map_err(|error| format!("relay agent managed-policy query failed: {error}"))?, + ); + } + + let mut agents = nostr_convert::relay_agents_from_directory_events( + &directory_events, + &managed_agent_events, + &profile_events, + ); + agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey)); + for agent in &mut agents { + agent.channel_ids = member_agent_channel_ids + .get(&agent.pubkey) + .cloned() + .unwrap_or_default(); + } + Ok(agents) +} + +#[tauri::command] +pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { + list_relay_agents_for_state(&state).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_author_queries_prevent_noisy_agent_crowd_out() { + let pubkeys = vec!["a".repeat(64), "b".repeat(64)]; + + let filters = exact_author_filters(&pubkeys, 10100); + + assert_eq!(filters.len(), 2); + for (filter, pubkey) in filters.iter().zip(pubkeys) { + assert_eq!(filter["authors"], serde_json::json!([pubkey])); + assert_eq!(filter["kinds"], serde_json::json!([10100])); + assert_eq!(filter["limit"], 1); + } + } + + #[test] + fn managed_policy_queries_are_exact_coordinates() { + let candidates = vec!["a".repeat(64), "b".repeat(64)]; + let owners = std::collections::HashMap::from([ + (candidates[0].clone(), "c".repeat(64)), + (candidates[1].clone(), "d".repeat(64)), + ]); + + let filters = managed_policy_filters(&candidates, &owners); + + assert_eq!(filters.len(), 2); + for (filter, candidate) in filters.iter().zip(candidates) { + assert_eq!(filter["authors"].as_array().map(Vec::len), Some(1)); + assert_eq!(filter["kinds"], serde_json::json!([30177])); + assert_eq!(filter["#d"], serde_json::json!([candidate])); + assert_eq!(filter["limit"], 1); + } + } + + #[test] + fn relay_filter_batches_do_not_exceed_protocol_limit() { + let pubkeys: Vec<_> = (0..25).map(|index| format!("{index:064x}")).collect(); + let filters = exact_author_filters(&pubkeys, 0); + + let batch_sizes: Vec<_> = filters + .chunks(RELAY_FILTER_BATCH_SIZE) + .map(<[_]>::len) + .collect(); + + assert_eq!(batch_sizes, vec![10, 10, 5]); + } +} + +#[cfg(all(test, not(target_os = "windows")))] +mod real_relay_tests { + use super::*; + use crate::{app_state::build_app_state, events, managed_agents, relay}; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + + fn relay_ws_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3037".to_string()) + } + + fn state_for(keys: Keys) -> AppState { + let state = build_app_state(); + *state.keys.lock().unwrap() = keys; + *state.relay_url_override.lock().unwrap() = Some(relay_ws_url()); + state + } + + async fn publish(builder: EventBuilder, signer: &Keys, state: &AppState) { + relay::submit_event_with_keys(builder, state, signer, None) + .await + .expect("publish real-relay fixture"); + } + + #[tokio::test] + #[ignore] + async fn newly_retained_managed_policy_replaces_open_access_immediately_on_real_relay() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let state = state_for(owner.clone()); + let db_dir = tempfile::tempdir().unwrap(); + let db_path = db_dir.path().join("retention.sqlite3"); + let initial_content = serde_json::json!({ + "name": "Immediate Policy Probe", + "parallelism": 1, + "respond_to": "anyone" + }) + .to_string(); + let initial_event = + EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), initial_content) + .tags([Tag::parse(["d", &agent.public_key().to_hex()]).unwrap()]) + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs().saturating_sub(1), + )); + publish(initial_event, &owner, &state).await; + + let updated_content = serde_json::json!({ + "name": "Immediate Policy Probe", + "parallelism": 1, + "respond_to": "owner-only" + }) + .to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), updated_content) + .tags([Tag::parse(["d", &agent.public_key().to_hex()]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + + { + use managed_agents::retention::{open_retention_db, retain_event, RetainedEvent}; + use nostr::JsonUtil; + + let conn = open_retention_db(&db_path).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner.public_key().to_hex(), + d_tag: agent.public_key().to_hex(), + content: event.content.clone(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .unwrap(); + } + + let flushed = managed_agents::persona_events::flush_pending_events_at( + &db_path, + &state, + &relay_ws_url(), + &owner, + ) + .await + .expect("create-path immediate policy flush"); + assert_eq!(flushed, 1); + + let queried = query_relay( + &state, + &[serde_json::json!({ + "kinds": [KIND_MANAGED_AGENT], + "authors": [owner.public_key().to_hex()], + "#d": [agent.public_key().to_hex()], + "limit": 1 + })], + ) + .await + .expect("query immediately flushed policy"); + assert_eq!(queried.len(), 1); + assert_eq!(queried[0].id, event.id); + assert!(queried[0].content.contains("\"respond_to\":\"owner-only\"")); + } + + #[tokio::test] + #[ignore] + async fn cross_identity_managed_agent_is_discovered_and_emits_exact_p_tag_from_real_relay() { + let owner = Keys::generate(); + let viewer = Keys::generate(); + let agent = Keys::generate(); + let owner_state = state_for(owner.clone()); + let viewer_state = state_for(viewer.clone()); + let channel_id = Uuid::new_v4(); + + publish( + events::build_create_channel( + channel_id, + &format!("agent-discovery-e2e-{channel_id}"), + "private", + "stream", + None, + None, + ) + .unwrap(), + &owner, + &owner_state, + ) + .await; + publish( + events::build_add_member(channel_id, &viewer.public_key().to_hex(), None).unwrap(), + &owner, + &owner_state, + ) + .await; + publish( + events::build_add_member(channel_id, &agent.public_key().to_hex(), Some("bot")) + .unwrap(), + &owner, + &owner_state, + ) + .await; + + let compat_owner = nostr::Keys::parse(&owner.secret_key().to_secret_hex()).unwrap(); + let compat_agent = nostr::PublicKey::from_hex(&agent.public_key().to_hex()).unwrap(); + let auth_tag = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "").unwrap(); + relay::sync_managed_agent_profile( + &owner_state, + &relay_ws_url(), + &agent, + "Agent Probe", + None, + Some(&auth_tag), + ) + .await + .expect("publish agent kind:0 profile"); + + let managed_content = serde_json::json!({ + "name": "Agent Probe", + "parallelism": 1, + "respond_to": "anyone" + }) + .to_string(); + publish( + EventBuilder::new(Kind::Custom(30177), managed_content).tags([Tag::parse([ + "d", + &agent.public_key().to_hex(), + ]) + .unwrap()]), + &owner, + &owner_state, + ) + .await; + + let agents = list_relay_agents_for_state(&viewer_state) + .await + .expect("query production relay directory"); + assert_eq!(agents.len(), 1, "real relay directory returned {agents:?}"); + assert_eq!(agents[0].pubkey, agent.public_key().to_hex()); + assert_eq!(agents[0].name, "Agent Probe"); + assert_eq!(agents[0].channel_ids, vec![channel_id.to_string()]); + + // Exercise the final protocol boundary, not merely the directory DTO: + // selecting this candidate must become the agent's exact lowercase + // `p` tag in the signed stream event. + let mention_pubkey = agents[0].pubkey.as_str(); + let signed_message = events::build_message( + channel_id, + "Ask @Agent Probe to reply", + None, + &[mention_pubkey], + &[], + &[], + &[], + &[], + None, + &relay_ws_url(), + ) + .unwrap() + .sign_with_keys(&viewer) + .unwrap(); + let emitted_mentions: Vec<_> = signed_message + .tags + .iter() + .filter_map(|tag| { + let tag = tag.as_slice(); + (tag.first().map(String::as_str) == Some("p")) + .then(|| tag.get(1).cloned()) + .flatten() + }) + .collect(); + assert_eq!(emitted_mentions, vec![agent.public_key().to_hex()]); + } +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 183f27dba12..85d9da4dfa7 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -22,8 +22,8 @@ use crate::{ find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas, managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, - AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, - DEFAULT_ACP_COMMAND, + AgentModelInfo, AgentModelsResponse, ManagedAgentRecord, UpdateManagedAgentRequest, + UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -697,217 +697,10 @@ use databricks::{ }; use databricks::{discover_databricks_models, DatabricksAuthIntent}; -/// Update mutable fields on an existing managed agent record. -/// -/// Does NOT auto-restart the agent. Runtime config changes (system prompt, -/// parallelism, commands, toolsets) take effect on the next agent spawn. -/// Name changes are synced to the relay immediately via a kind:0 re-publish. -#[tauri::command] -pub async fn update_managed_agent( - input: UpdateManagedAgentRequest, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - // Phase 1: local save (synchronous, under lock) - let (summary, sync_params, rollback) = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - let (_, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); - } - - let record = find_managed_agent_mut(&mut records, &input.pubkey)?; - let previous_record = record.clone(); - - let mut name_changed = false; - if let Some(name_update) = input.name { - let trimmed = name_update.trim().to_string(); - if !trimmed.is_empty() && trimmed != record.name { - record.name = trimmed; - name_changed = true; - } - } - apply_model_provider_prompt_update( - record, - input.model, - input.provider, - input.system_prompt, - )?; - if let Some(parallelism) = input.parallelism { - record.parallelism = parallelism; - } - // turn_timeout_seconds is intentionally not applied here — - // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness. - // Use idle_timeout_seconds or max_turn_duration_seconds instead. - // Store the relay override exactly as supplied (trimmed). An explicit - // value pins the agent; empty falls back to the workspace relay at - // read-time. A name-only edit (relay_url == None) leaves the pin intact. - if let Some(relay_url) = input.relay_url { - record.relay_url = relay_url.trim().to_string(); - } - if let Some(acp_command) = input.acp_command { - record.acp_command = acp_command; - } - // Harness edit: the persona's runtime is authoritative, so an explicit - // `agent_command_override` is persisted ONLY when the user picks a - // command that diverges from the persona, and the empty/whitespace - // "Inherit from persona" sentinel clears both the pin and the - // materialized record runtime. A name-only edit - // (`agent_command == None`) leaves the pin intact. `harness_override` - // threads the user's explicit intent — see `apply_agent_command_update` - // and `update_time_agent_command_override` for the full resolution - // rules. - if let Some(agent_command) = input.agent_command { - let personas = load_personas(&app).unwrap_or_default(); - crate::managed_agents::apply_agent_command_update( - record, - &personas, - &agent_command, - input.harness_override, - ); - } - if let Some(agent_args) = input.agent_args { - record.agent_args = agent_args; - } - // mcp_command is intentionally not applied here — the effective MCP - // command is always catalog-derived (known_acp_runtime at spawn time) - // and the per-record field is never read by the runtime. - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - record.env_vars = env_vars; - } - - // Native provider/model fields are authoritative. Keep the typed marker - // derived for new records while retaining legacy typed records for - // non-native providers. - if record.provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { - let model_ref = record - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID) - .to_string(); - record.model = Some(model_ref.clone()); - record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref }); - } - - // Inbound author gate: merge patch onto current values, then validate - // the merged state. This lets a single update switch to Allowlist AND - // supply pubkeys atomically. - let prospective_mode = input.respond_to.unwrap_or(record.respond_to); - let prospective_allowlist = match input.respond_to_allowlist.as_ref() { - Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?, - None => record.respond_to_allowlist.clone(), - }; - if prospective_mode == crate::managed_agents::RespondTo::Allowlist - && prospective_allowlist.is_empty() - { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" - .to_string(), - ); - } - record.respond_to = prospective_mode; - // Preserve the persisted allowlist across mode toggles — only replace - // when the caller explicitly supplied a new list. - if input.respond_to_allowlist.is_some() { - record.respond_to_allowlist = prospective_allowlist; - } - - record.updated_at = now_iso(); - - save_managed_agents(&app, &records)?; - - let record = records - .iter() - .find(|r| r.pubkey == input.pubkey) - .ok_or_else(|| format!("agent {} not found", input.pubkey))?; - - // Publish the edit to the relay. After-save, inside the lock, before - // any .await. The retention upsert hashes the opt-IN projection, so an - // update that touched only runtime/local fields is a no-op publish. - super::agents::retain_managed_agent_pending(&app, &state, record); - - let sync_params = if name_changed { - let agent_keys = Keys::parse(&record.private_key_nsec) - .map_err(|e| format!("failed to parse agent keys: {e}"))?; - // Re-publish the renamed profile to the agent's effective relay: - // an explicit per-agent relay wins; empty falls back to workspace. - let relay_url = crate::relay::effective_agent_relay_url( - &record.relay_url, - &relay_ws_url_with_override(&state), - ); - let display_name = record.name.clone(); - // Avatar fallback derives from the EFFECTIVE harness (persona-wins), - // not the frozen snapshot, so an inherited harness picks the right - // default avatar. - let personas = load_personas(&app).unwrap_or_default(); - let effective_command = crate::managed_agents::record_agent_command(record, &personas); - let avatar_url = record - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&effective_command)); - let auth_tag = record.auth_tag.clone(); - Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) - } else { - None - }; - - let summary = { - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary( - &app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), - )? - }; - let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record)); - (summary, sync_params, rollback) - }; // lock dropped here - - try_regenerate_nest(&app); - - // Phase 2: relay profile sync (async, outside lock). A rename is committed - // only when this succeeds; otherwise restore the complete pre-edit record - // so Desktop and the relay keep one authoritative name. - if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { - if let Err(sync_error) = sync_managed_agent_profile( - &state, - &relay_url, - &agent_keys, - &display_name, - avatar_url.as_deref(), - auth_tag.as_deref(), - ) - .await - { - let rollback = rollback.ok_or_else(|| { - "missing local rollback state after relay profile sync failure".to_string() - })?; - rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback)?; - return Err(format!( - "Agent rename failed because its relay profile could not be updated. No changes were saved: {sync_error}" - )); - } - } - - Ok(UpdateManagedAgentResponse { - agent: summary, - profile_sync_error: None, - }) -} +#[path = "agent_models_update.rs"] +mod update; +pub use update::update_managed_agent; +pub(super) use update::{flush_managed_agent_policy, managed_agent_access_policy_changed}; // ── Model normalization ─────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 79dd7263c61..c00cb5cd2d9 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -1,5 +1,32 @@ use super::*; +#[test] +fn access_policy_change_requires_runtime_refresh_for_effective_gate_changes() { + use crate::managed_agents::RespondTo; + + let allowlist_a = vec!["a".repeat(64)]; + let allowlist_b = vec!["b".repeat(64)]; + + assert!(managed_agent_access_policy_changed( + RespondTo::Anyone, + &[], + RespondTo::OwnerOnly, + &[], + )); + assert!(managed_agent_access_policy_changed( + RespondTo::Allowlist, + &allowlist_a, + RespondTo::Allowlist, + &allowlist_b, + )); + assert!(!managed_agent_access_policy_changed( + RespondTo::OwnerOnly, + &allowlist_a, + RespondTo::OwnerOnly, + &allowlist_b, + )); +} + #[test] fn openai_model_normalization_keeps_agent_text_models() { let models = normalize_openai_compatible_models( diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs new file mode 100644 index 00000000000..922bee2e3e0 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -0,0 +1,360 @@ +use super::*; + +pub(crate) fn managed_agent_access_policy_changed( + current_mode: crate::managed_agents::RespondTo, + current_allowlist: &[String], + prospective_mode: crate::managed_agents::RespondTo, + prospective_allowlist: &[String], +) -> bool { + prospective_mode != current_mode + || (prospective_mode == crate::managed_agents::RespondTo::Allowlist + && prospective_allowlist != current_allowlist) +} + +fn ensure_access_policy_change_supported( + record: &ManagedAgentRecord, + access_policy_changed: bool, +) -> Result<(), String> { + if access_policy_changed + && record.backend != crate::managed_agents::BackendKind::Local + && record.backend_agent_id.is_some() + { + return Err( + "Access cannot be changed while this provider-backed agent is deployed because the provider protocol has no explicit stop or revocation acknowledgement. Stop or recreate the provider agent first." + .to_string(), + ); + } + Ok(()) +} + +/// Flush a retained managed-agent policy, preserving any earlier profile error. +pub(crate) async fn flush_managed_agent_policy( + app: &AppHandle, + state: &AppState, + existing_error: Option, +) -> Option { + match crate::managed_agents::persona_events::flush_active_pending_events(app, state).await { + Ok(_) => existing_error, + Err(error) => Some(match existing_error { + Some(profile_error) => { + format!("{profile_error}; managed policy sync failed: {error}") + } + None => format!("managed policy sync failed: {error}"), + }), + } +} + +/// Update mutable fields on an existing managed agent record. +/// +/// Most runtime config changes take effect on the next agent spawn. Access +/// policy changes stop active local pairs before saving and restart those exact +/// pairs after the relay policy is flushed. +#[tauri::command] +pub async fn update_managed_agent( + input: UpdateManagedAgentRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // Phase 1: local save (synchronous, under lock) + let (mut summary, sync_params, rollback, access_policy_changed, access_restart_relays) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let (_, exited_pubkeys) = + sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + for pubkey in &exited_pubkeys { + state.clear_agent_session_caches(pubkey); + } + + let record = find_managed_agent_mut(&mut records, &input.pubkey)?; + let previous_record = record.clone(); + + let mut name_changed = false; + if let Some(name_update) = input.name { + let trimmed = name_update.trim().to_string(); + if !trimmed.is_empty() && trimmed != record.name { + record.name = trimmed; + name_changed = true; + } + } + apply_model_provider_prompt_update( + record, + input.model, + input.provider, + input.system_prompt, + )?; + if let Some(parallelism) = input.parallelism { + record.parallelism = parallelism; + } + // turn_timeout_seconds is intentionally not applied here — + // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness. + // Use idle_timeout_seconds or max_turn_duration_seconds instead. + // Store the relay override exactly as supplied (trimmed). An explicit + // value pins the agent; empty falls back to the workspace relay at + // read-time. A name-only edit (relay_url == None) leaves the pin intact. + if let Some(relay_url) = input.relay_url { + record.relay_url = relay_url.trim().to_string(); + } + if let Some(acp_command) = input.acp_command { + record.acp_command = acp_command; + } + // Harness edit: the persona's runtime is authoritative, so an explicit + // `agent_command_override` is persisted ONLY when the user picks a + // command that diverges from the persona, and the empty/whitespace + // "Inherit from persona" sentinel clears both the pin and the + // materialized record runtime. A name-only edit + // (`agent_command == None`) leaves the pin intact. `harness_override` + // threads the user's explicit intent — see `apply_agent_command_update` + // and `update_time_agent_command_override` for the full resolution + // rules. + if let Some(agent_command) = input.agent_command { + let personas = load_personas(&app).unwrap_or_default(); + crate::managed_agents::apply_agent_command_update( + record, + &personas, + &agent_command, + input.harness_override, + ); + } + if let Some(agent_args) = input.agent_args { + record.agent_args = agent_args; + } + // mcp_command is intentionally not applied here — the effective MCP + // command is always catalog-derived (known_acp_runtime at spawn time) + // and the per-record field is never read by the runtime. + if let Some(env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(&env_vars)?; + record.env_vars = env_vars; + } + + // Native provider/model fields are authoritative. Keep the typed marker + // derived for new records while retaining legacy typed records for + // non-native providers. + if record.provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { + let model_ref = record + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID) + .to_string(); + record.model = Some(model_ref.clone()); + record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref }); + } + + // Inbound author gate: merge patch onto current values, then validate + // the merged state. This lets a single update switch to Allowlist AND + // supply pubkeys atomically. + let prospective_mode = input.respond_to.unwrap_or(record.respond_to); + let prospective_allowlist = match input.respond_to_allowlist.as_ref() { + Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?, + None => record.respond_to_allowlist.clone(), + }; + if prospective_mode == crate::managed_agents::RespondTo::Allowlist + && prospective_allowlist.is_empty() + { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" + .to_string(), + ); + } + let access_policy_changed = managed_agent_access_policy_changed( + record.respond_to, + &record.respond_to_allowlist, + prospective_mode, + &prospective_allowlist, + ); + ensure_access_policy_change_supported(record, access_policy_changed)?; + + // Revoke the currently running local gate before persisting or + // advertising the replacement policy. Keeping this inside the same + // store/process critical section prevents another command or a status + // refresh from observing a saved narrow policy while the old broad + // process is still alive. A stop failure aborts before mutation. + let mut access_restart_relays = Vec::new(); + if access_policy_changed && record.backend == crate::managed_agents::BackendKind::Local { + access_restart_relays = + crate::managed_agents::managed_agent_runtime_keys(&runtimes, &record.pubkey) + .into_iter() + .map(|key| key.relay_url) + .collect(); + if access_restart_relays.is_empty() && record.runtime_pid.is_some() { + access_restart_relays.push(crate::relay::effective_agent_relay_url( + &record.relay_url, + &relay_ws_url_with_override(&state), + )); + } + if !access_restart_relays.is_empty() { + crate::managed_agents::stop_managed_agent_process(&app, record, &mut runtimes)?; + } + } + + record.respond_to = prospective_mode; + // Preserve the persisted allowlist across mode toggles — only replace + // when the caller explicitly supplied a new list. + if input.respond_to_allowlist.is_some() { + record.respond_to_allowlist = prospective_allowlist; + } + + record.updated_at = now_iso(); + + save_managed_agents(&app, &records)?; + + let record = records + .iter() + .find(|r| r.pubkey == input.pubkey) + .ok_or_else(|| format!("agent {} not found", input.pubkey))?; + + // Publish the edit to the relay. After-save, inside the lock, before + // any .await. The retention upsert hashes the opt-IN projection, so an + // update that touched only runtime/local fields is a no-op publish. + super::super::agents::retain_managed_agent_pending(&app, &state, record); + + let sync_params = if name_changed { + let agent_keys = Keys::parse(&record.private_key_nsec) + .map_err(|e| format!("failed to parse agent keys: {e}"))?; + // Re-publish the renamed profile to the agent's effective relay: + // an explicit per-agent relay wins; empty falls back to workspace. + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &relay_ws_url_with_override(&state), + ); + let display_name = record.name.clone(); + // Avatar fallback derives from the EFFECTIVE harness (persona-wins), + // not the frozen snapshot, so an inherited harness picks the right + // default avatar. + let personas = load_personas(&app).unwrap_or_default(); + let effective_command = crate::managed_agents::record_agent_command(record, &personas); + let avatar_url = record + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&effective_command)); + let auth_tag = record.auth_tag.clone(); + Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) + } else { + None + }; + + let summary = { + let personas = load_personas(&app).unwrap_or_default(); + build_managed_agent_summary( + &app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), + )? + }; + let rollback = name_changed + .then(|| AgentUpdateRollback::new(previous_record, record, access_policy_changed)); + ( + summary, + sync_params, + rollback, + access_policy_changed, + access_restart_relays, + ) + }; // lock dropped here + + try_regenerate_nest(&app); + + // Phase 2: relay sync (async, outside lock). The owner-signed managed + // policy is security-sensitive: an access reduction must replace the old + // relay head before this command returns rather than waiting for the + // 30-second retention sweep. The flush remains durable/best-effort; rows a + // relay does not accept stay pending for the background retry. + let mut profile_sync_error = + crate::managed_agents::persona_events::flush_active_pending_events(&app, &state) + .await + .err() + .map(|error| format!("managed policy sync failed: {error}")); + if profile_sync_error.is_none() + && crate::managed_agents::persona_events::active_pending_event( + &app, + &state, + buzz_core_pkg::kind::KIND_MANAGED_AGENT, + &summary.pubkey, + )? + { + profile_sync_error = Some( + "managed policy sync failed: relay did not accept the updated policy; retry queued" + .to_string(), + ); + } + + // A rename is committed only when profile sync succeeds; otherwise restore + // the complete pre-edit record so Desktop and the relay keep one + // authoritative name. + if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { + if let Err(sync_error) = sync_managed_agent_profile( + &state, + &relay_url, + &agent_keys, + &display_name, + avatar_url.as_deref(), + auth_tag.as_deref(), + ) + .await + { + let rollback = rollback.ok_or_else(|| { + "missing local rollback state after relay profile sync failure".to_string() + })?; + rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback)?; + let restart_suffix = if access_restart_relays.is_empty() { + String::new() + } else { + match super::super::agents::start_local_agent_pairs_with_preflight( + &app, + &state, + &summary.pubkey, + &access_restart_relays, + ) + .await + { + Ok(_) => String::new(), + Err(error) => format!( + " The runtime also failed to restart with the kept access policy: {error}" + ), + } + }; + let rollback_message = if access_policy_changed { + "The access policy change was kept, but other edits were rolled back" + } else { + "No changes were saved" + }; + return Err(format!( + "Agent rename failed because its relay profile could not be updated. {rollback_message}: {sync_error}.{restart_suffix}" + )); + } + } + + if !access_restart_relays.is_empty() { + summary = super::super::agents::start_local_agent_pairs_with_preflight( + &app, + &state, + &summary.pubkey, + &access_restart_relays, + ) + .await + .map_err(|error| { + format!( + "Agent access was saved and published, but its runtime failed to restart with the new policy: {error}" + ) + })?; + } + + Ok(UpdateManagedAgentResponse { + agent: summary, + profile_sync_error: profile_sync_error.take(), + }) +} + +#[cfg(test)] +#[path = "agent_models_update_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_models_update_tests.rs b/desktop/src-tauri/src/commands/agent_models_update_tests.rs new file mode 100644 index 00000000000..b9fd0bd1839 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_update_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +fn provider_record(deployed: bool) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = crate::managed_agents::BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }; + record.backend_agent_id = deployed.then(|| "deployment".to_string()); + record +} + +#[test] +fn deployed_provider_rejects_access_edits_that_cannot_be_revoked() { + let error = ensure_access_policy_change_supported(&provider_record(true), true) + .expect_err("deployed provider access edit must fail closed"); + assert!(error.contains("no explicit stop or revocation acknowledgement")); +} + +#[test] +fn undeployed_provider_accepts_access_edits() { + ensure_access_policy_change_supported(&provider_record(false), true) + .expect("no running provider deployment can retain stale access"); +} diff --git a/desktop/src-tauri/src/commands/agent_update_rollback.rs b/desktop/src-tauri/src/commands/agent_update_rollback.rs index 2745b3cd22b..78734797f04 100644 --- a/desktop/src-tauri/src/commands/agent_update_rollback.rs +++ b/desktop/src-tauri/src/commands/agent_update_rollback.rs @@ -11,13 +11,19 @@ use crate::{ pub(super) struct AgentUpdateRollback { attempted_record: ManagedAgentRecord, previous_record: ManagedAgentRecord, + preserve_access_policy: bool, } impl AgentUpdateRollback { - pub(super) fn new(previous_record: ManagedAgentRecord, attempted: &ManagedAgentRecord) -> Self { + pub(super) fn new( + previous_record: ManagedAgentRecord, + attempted: &ManagedAgentRecord, + preserve_access_policy: bool, + ) -> Self { Self { attempted_record: attempted.clone(), previous_record, + preserve_access_policy, } } } @@ -64,6 +70,13 @@ fn restore_agent_update( attempted_with_current_runtime != rollback.attempted_record }; let mut restored = rollback.previous_record; + if rollback.preserve_access_policy { + restored.respond_to = current.respond_to; + restored + .respond_to_allowlist + .clone_from(¤t.respond_to_allowlist); + restored.updated_at.clone_from(¤t.updated_at); + } copy_runtime_state(current, &mut restored); if runtime_changed { restored.updated_at.clone_from(¤t.updated_at); @@ -137,7 +150,7 @@ mod tests { attempted.name = "New name".to_string(); attempted.model = Some("new-model".to_string()); attempted.updated_at = "attempt".to_string(); - let rollback = AgentUpdateRollback::new(previous, &attempted); + let rollback = AgentUpdateRollback::new(previous, &attempted, false); let mut records = vec![attempted]; restore_agent_update(&mut records, "abcd1234", rollback) @@ -148,13 +161,34 @@ mod tests { assert_eq!(records[0].updated_at, "before"); } + #[test] + fn failed_profile_sync_keeps_a_tightened_access_policy() { + let previous = record("Old name", "before"); + let mut attempted = previous.clone(); + attempted.name = "New name".to_string(); + attempted.respond_to = crate::managed_agents::RespondTo::OwnerOnly; + attempted.updated_at = "attempt".to_string(); + let rollback = AgentUpdateRollback::new(previous, &attempted, true); + let mut records = vec![attempted]; + + restore_agent_update(&mut records, "abcd1234", rollback) + .expect("matching attempted update rolls back non-access fields"); + + assert_eq!(records[0].name, "Old name"); + assert_eq!( + records[0].respond_to, + crate::managed_agents::RespondTo::OwnerOnly + ); + assert_eq!(records[0].updated_at, "attempt"); + } + #[test] fn failed_profile_sync_does_not_overwrite_a_newer_agent_update() { let previous = record("Old name", "before"); let mut attempted = previous.clone(); attempted.name = "New name".to_string(); attempted.updated_at = "attempt".to_string(); - let rollback = AgentUpdateRollback::new(previous, &attempted); + let rollback = AgentUpdateRollback::new(previous, &attempted, false); let mut newer = attempted; newer.name = "Newest name".to_string(); newer.updated_at = "newer".to_string(); @@ -175,7 +209,7 @@ mod tests { attempted.name = "New name".to_string(); attempted.model = Some("new-model".to_string()); attempted.updated_at = "attempt".to_string(); - let rollback = AgentUpdateRollback::new(previous, &attempted); + let rollback = AgentUpdateRollback::new(previous, &attempted, false); let mut churned = attempted; churned.runtime_pid = None; churned.last_stopped_at = Some("stopped".to_string()); diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 26136719706..3d38f37432c 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,15 +6,14 @@ use super::managed_agent_definition::validate_create_definition; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, current_instance_id, discover_provider_candidates, - ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, stop_managed_agent_workspace_pair, - sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, - ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, - DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + build_managed_agent_summary, current_instance_id, ensure_persona_is_active, + find_managed_agent_mut, load_managed_agents, load_personas, load_teams, + managed_agent_avatar_url, normalize_agent_args, resolve_provider_binary, + save_managed_agents, start_managed_agent_process, stop_managed_agent_process, + stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, + validate_provider_config, BackendKind, CreateManagedAgentRequest, + CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -441,72 +440,7 @@ pub(super) async fn start_local_agent_with_preflight( ) } -/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via -/// spawn_blocking, and persists the result (backend_agent_id or last_error). -/// -/// Idempotency: calling deploy on an already-deployed agent sends the same payload -/// again. Providers are expected to handle this as an update-in-place or no-op — -/// the protocol does not include an explicit `undeploy` operation (deferred to v2). -/// -/// Returns Ok(()) on success, Err(message) on failure. Either way the record is -/// updated and saved before returning. -async fn deploy_to_provider( - app: &AppHandle, - state: &AppState, - pubkey: &str, - provider_id: &str, - config: &serde_json::Value, - agent_json: serde_json::Value, - cached_binary_path: Option<&str>, -) -> Result<(), String> { - // Resolve via discovered candidates only. Cached path must match BOTH - // "is a discovered candidate" AND "belongs to this provider_id". A tampered - // record cannot redirect deploys to a different provider's binary. - let bin_path = cached_binary_path - .map(std::path::PathBuf::from) - .filter(|p| p.exists()) - .map(|p| p.canonicalize().unwrap_or(p)) - .filter(|canonical| { - discover_provider_candidates().iter().any(|(id, cp)| { - id == provider_id && cp.canonicalize().ok().as_ref() == Some(canonical) - }) - }) - .map_or_else(|| resolve_provider_binary(provider_id), Ok)?; - - let config_clone = config.clone(); - let deploy_result = - tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone)) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))?; - - // Persist result under lock. - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let rec = records - .iter_mut() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - - match deploy_result { - Ok(backend_agent_id) => { - rec.backend_agent_id = Some(backend_agent_id); - rec.last_started_at = Some(now_iso()); - rec.updated_at = now_iso(); - rec.last_error = None; - } - Err(ref e) => { - rec.last_error = Some(e.clone()); - rec.updated_at = now_iso(); - save_managed_agents(app, &records)?; - return Err(e.clone()); - } - } - save_managed_agents(app, &records)?; - Ok(()) -} +pub(crate) use provider_deploy::deploy_to_provider; // Async so the blocking body (disk reads of agent/persona records, per-agent // process-liveness syscalls, and a possible save) runs on Tauri's worker pool @@ -870,6 +804,7 @@ pub async fn create_managed_agent( runtime_pid: None, backend: input.backend.clone(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path, persona_team_dir: None, persona_name_in_team: None, @@ -979,7 +914,7 @@ pub async fn create_managed_agent( &resolved_relay_url, &relay_ws_url_with_override(&state), ); - let profile_sync_error = (sync_managed_agent_profile( + let mut profile_sync_error = (sync_managed_agent_profile( &state, &profile_relay_url, &agent_keys, @@ -989,12 +924,11 @@ pub async fn create_managed_agent( ) .await) .err(); + profile_sync_error = + super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await; - // ── Phase 5: provider deploy (async, outside lock) ─────────────────────── let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { if let BackendKind::Provider { ref id, ref config } = input.backend { - // Read the saved record to build the deploy payload (record has the - // canonical field values after Phase 3 normalization). let agent_json = { let _g = state .managed_agents_store_lock @@ -1354,7 +1288,8 @@ pub async fn delete_managed_agent( #[path = "agents_deploy.rs"] mod deploy; pub(super) mod provider_access; -use deploy::build_deploy_payload; +mod provider_deploy; +pub(super) use deploy::build_deploy_payload; #[cfg(test)] use deploy::{deploy_payload_json, DeployProjections}; #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs index 467230e56f8..5b90500ee66 100644 --- a/desktop/src-tauri/src/commands/agents/provider_access.rs +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -15,7 +15,9 @@ pub(super) fn needs_reconciliation_with_policy( record: &ManagedAgentRecord, owner_only_access: bool, ) -> bool { - owner_only_access && record.backend != BackendKind::Local && record.backend_agent_id.is_some() + (owner_only_access || record.provider_policy_pending) + && record.backend != BackendKind::Local + && record.backend_agent_id.is_some() } #[derive(Debug)] @@ -50,25 +52,23 @@ fn collect_targets_with( .collect() } -/// Redeploy every existing provider agent in an owner-only access build. +/// Redeploy existing provider agents whose access policy requires enforcement. /// -/// The saved `backend_agent_id` only proves that some provider deployment -/// exists. A marked build sends the current owner-only payload before each -/// community UI load. Workspace apply fails closed if any provider rejects it. +/// Owner-only builds refresh every existing deployment before each community UI +/// load. All builds also retry records whose saved policy has not yet been +/// acknowledged by a successful provider deployment. Workspace apply fails +/// closed if any selected provider rejects the current policy. pub(crate) async fn reconcile_on_workspace_apply( app: &AppHandle, state: &AppState, ) -> Result<(), String> { - if !crate::managed_agents::owner_only_access_build() { - return Ok(()); - } - + let owner_only_access = crate::managed_agents::owner_only_access_build(); let targets = { let _store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - collect_targets_with(load_managed_agents(app)?, true, |record| { + collect_targets_with(load_managed_agents(app)?, owner_only_access, |record| { super::build_deploy_payload(app, state, record) }) }; @@ -110,7 +110,7 @@ pub(crate) async fn reconcile_on_workspace_apply( Ok(()) } -fn persist_failure( +pub(crate) fn persist_failure( app: &AppHandle, state: &AppState, pubkey: &str, @@ -180,17 +180,53 @@ mod tests { } #[test] - fn unmarked_build_collects_no_upgrade_targets() { - let records = vec![record( + fn unmarked_build_collects_only_pending_targets() { + let mut pending = record( BackendKind::Provider { - id: "provider".into(), + id: "pending-provider".into(), config: serde_json::json!({}), }, - Some("existing"), - )]; + Some("existing-pending"), + ); + pending.pubkey = "pending-agent".into(); + pending.provider_policy_pending = true; + let ordinary = record( + BackendKind::Provider { + id: "ordinary-provider".into(), + config: serde_json::json!({}), + }, + Some("existing-ordinary"), + ); + + let targets = collect_targets_with(vec![ordinary, pending], false, |record| { + Ok(serde_json::json!({"pubkey": record.pubkey})) + }); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].pubkey, "pending-agent"); + assert_eq!(targets[0].provider_id, "pending-provider"); + assert_eq!( + targets[0].agent_json.as_ref().unwrap()["pubkey"], + "pending-agent" + ); + } - assert!( - collect_targets_with(records, false, |_| { Ok(serde_json::Value::Null) }).is_empty() + #[test] + fn pending_policy_requires_an_existing_provider_deployment() { + let mut undeployed = record( + BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }, + None, ); + undeployed.provider_policy_pending = true; + let mut local = record(BackendKind::Local, Some("stale-provider-id")); + local.provider_policy_pending = true; + + assert!(collect_targets_with(vec![undeployed, local], false, |_| { + Ok(serde_json::Value::Null) + }) + .is_empty()); } } diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs new file mode 100644 index 00000000000..cdbdd787e7e --- /dev/null +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -0,0 +1,211 @@ +use std::sync::Arc; + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + discover_provider_candidates, load_managed_agents, provider_deploy, + resolve_provider_binary, save_managed_agents, BackendKind, + }, + util::now_iso, +}; + +use super::build_deploy_payload; + +/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via +/// spawn_blocking, and persists the result (backend_agent_id or last_error). +/// +/// Idempotency: calling deploy on an already-deployed agent sends the same payload +/// again. Providers are expected to handle this as an update-in-place or no-op. +/// The protocol has no explicit `undeploy` operation or acknowledgement that an +/// existing process stopped, so a successful redeploy delegates access-policy +/// revocation semantics to the provider implementation (deferred to v2). +/// Returns Ok(()) on success, Err(message) on failure. Either way the record is +/// updated and saved before returning. +pub(crate) async fn deploy_to_provider( + app: &AppHandle, + state: &AppState, + pubkey: &str, + _provider_id: &str, + _config: &serde_json::Value, + _agent_json: serde_json::Value, + _cached_binary_path: Option<&str>, +) -> Result<(), String> { + let deploy_lock = { + let mut locks = state + .provider_deploy_locks + .lock() + .map_err(|error| error.to_string())?; + Arc::clone( + locks + .entry(pubkey.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))), + ) + }; + let _deploy_guard = deploy_lock.lock().await; + // The payload may have waited behind another deployment. Rebuild it from + // the current record so the final provider invocation always carries the + // newest saved policy rather than the stale snapshot captured by its caller. + let (provider_id, config, cached_binary_path, agent_json) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let (provider_id, config) = match &record.backend { + BackendKind::Provider { id, config } => (id.clone(), config.clone()), + BackendKind::Local => return Err(format!("agent {pubkey} is not provider-backed")), + }; + ( + provider_id, + config, + record.provider_binary_path.clone(), + build_deploy_payload(app, state, record)?, + ) + }; + // Resolve via discovered candidates only. Cached path must match BOTH + // "is a discovered candidate" AND "belongs to this provider_id". A tampered + // record cannot redirect deploys to a different provider's binary. + let bin_path = cached_binary_path + .as_deref() + .map(std::path::PathBuf::from) + .filter(|p| p.exists()) + .map(|p| p.canonicalize().unwrap_or(p)) + .filter(|canonical| { + discover_provider_candidates().iter().any(|(id, cp)| { + id == &provider_id && cp.canonicalize().ok().as_ref() == Some(canonical) + }) + }) + .map_or_else(|| resolve_provider_binary(&provider_id), Ok)?; + + let deployed_agent_json = agent_json.clone(); + let config_clone = config.clone(); + let deploy_result = + tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone)) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))?; + + // Persist result under lock. + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let rec = records + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + + let result = apply_deploy_result(rec, deploy_result, &deployed_agent_json); + save_managed_agents(app, &records)?; + result +} + +fn policy_matches_payload( + record: &crate::managed_agents::ManagedAgentRecord, + deployed_agent_json: &serde_json::Value, +) -> bool { + deployed_agent_json + .get("respond_to") + .and_then(serde_json::Value::as_str) + == Some(record.respond_to.as_str()) + && deployed_agent_json.get("respond_to_allowlist") + == Some(&serde_json::json!(record.respond_to_allowlist)) +} + +fn apply_deploy_result( + record: &mut crate::managed_agents::ManagedAgentRecord, + deploy_result: Result, + deployed_agent_json: &serde_json::Value, +) -> Result<(), String> { + match deploy_result { + Ok(backend_agent_id) => { + record.backend_agent_id = Some(backend_agent_id); + if policy_matches_payload(record, deployed_agent_json) { + record.provider_policy_pending = false; + } + record.last_started_at = Some(now_iso()); + record.updated_at = now_iso(); + record.last_error = None; + Ok(()) + } + Err(error) => { + record.last_error = Some(error.clone()); + record.updated_at = now_iso(); + Err(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> crate::managed_agents::ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null, + "provider_policy_pending": true + })) + .unwrap() + } + + fn policy_payload(respond_to: &str) -> serde_json::Value { + serde_json::json!({"respond_to": respond_to, "respond_to_allowlist": []}) + } + + #[test] + fn successful_deploy_acknowledges_pending_policy() { + let mut record = record(); + + apply_deploy_result( + &mut record, + Ok("provider-agent".into()), + &policy_payload("owner-only"), + ) + .unwrap(); + + assert!(!record.provider_policy_pending); + assert_eq!(record.backend_agent_id.as_deref(), Some("provider-agent")); + assert_eq!(record.last_error, None); + } + + #[test] + fn successful_stale_deploy_preserves_newer_pending_policy() { + let mut record = record(); + record.respond_to = crate::managed_agents::RespondTo::Anyone; + + apply_deploy_result( + &mut record, + Ok("provider-agent".into()), + &policy_payload("owner-only"), + ) + .unwrap(); + + assert!(record.provider_policy_pending); + } + + #[test] + fn failed_deploy_preserves_pending_policy() { + let mut record = record(); + + let error = apply_deploy_result( + &mut record, + Err("provider unavailable".into()), + &policy_payload("owner-only"), + ) + .expect_err("deployment should fail"); + + assert_eq!(error, "provider unavailable"); + assert!(record.provider_policy_pending); + assert_eq!(record.last_error.as_deref(), Some("provider unavailable")); + } +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 47ee5f92d49..483eb60134f 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -121,7 +121,7 @@ pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result } /// Build the standard agent JSON payload for provider deploy calls. -pub(super) fn build_deploy_payload( +pub(crate) fn build_deploy_payload( app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f550a72e0c3..0176478bc5e 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -34,6 +34,7 @@ fn bare_agent_record( runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -625,6 +626,11 @@ fn provider_upgrade_reconciliation_targets_existing_deployments_only_in_marked_b &record, false )); + record.provider_policy_pending = true; + assert!(provider_access::needs_reconciliation_with_policy( + &record, false + )); + record.backend_agent_id = None; assert!(!provider_access::needs_reconciliation_with_policy( &record, true diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9bd..f4590f5d6e7 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -42,6 +42,7 @@ fn make_agent( runtime_pid, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index cbb23143533..5c38373f7cf 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -17,6 +17,21 @@ use crate::{ #[cfg(test)] mod inbound_tests; +#[derive(Debug)] +enum InboundRuntimeRefresh { + Local { + pubkey: String, + relay_urls: Vec, + }, + Provider { + pubkey: String, + provider_id: String, + config: serde_json::Value, + cached_binary_path: Option, + agent_json: Result, + }, +} + /// Apply an inbound kind:30175 persona event from the relay onto the local /// store. The frontend's live subscription invokes this per event for our own /// authored coordinate so Device B inherits Device A's edits. @@ -57,23 +72,84 @@ pub async fn reconcile_inbound_persona_event( arrival_relay_url: String, app: AppHandle, ) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, app) + let blocking_app = app.clone(); + let restart = tokio::task::spawn_blocking(move || { + reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, blocking_app) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + match restart { + Some(InboundRuntimeRefresh::Local { pubkey, relay_urls }) => { + let state = app.state::(); + super::super::agents::start_local_agent_pairs_with_preflight( + &app, + &state, + &pubkey, + &relay_urls, + ) + .await + .map_err(|error| { + format!( + "Inbound agent access was saved, but its runtime failed to restart with the new policy: {error}" + ) + })?; + } + Some(InboundRuntimeRefresh::Provider { + pubkey, + provider_id, + config, + cached_binary_path, + agent_json, + }) => { + let state = app.state::(); + let agent_json = match agent_json { + Ok(agent_json) => agent_json, + Err(error) => { + let message = format!( + "Inbound agent access was saved, but its provider deployment could not be refreshed safely: {error}" + ); + super::super::agents::provider_access::persist_failure( + &app, &state, &pubkey, &message, + )?; + let _ = app.emit("agents-data-changed", ()); + return Err(message); + } + }; + super::super::agents::deploy_to_provider( + &app, + &state, + &pubkey, + &provider_id, + &config, + agent_json, + cached_binary_path.as_deref(), + ) + .await + .map_err(|error| { + format!( + "Inbound agent access was saved, but its provider deployment failed to refresh with the new policy: {error}" + ) + })?; + } + None => {} + } + Ok(()) } fn reconcile_inbound_persona_event_blocking( event_json: String, arrival_relay_url: String, app: AppHandle, -) -> Result<(), String> { +) -> Result, String> { use crate::managed_agents::{ agent_events::managed_agent_content_from_event, load_managed_agents, load_teams, persona_events::persona_from_event, - retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, + retention::{ + inbound_event_outcome, open_retention_db, retain_inbound_event, InboundOutcome, + RetainedEvent, + }, save_managed_agents, save_teams, team_events::team_content_from_event, }; @@ -93,11 +169,12 @@ fn reconcile_inbound_persona_event_blocking( // in its `a` tag (`::`). Handled before the // upsert dispatch because its coordinate and retention key differ. if kind == KIND_DELETION { - return reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state); + reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state)?; + return Ok(None); } if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { - return Ok(()); + return Ok(None); } // The d-tag identifies the record within its kind. Persona derives it from @@ -137,25 +214,35 @@ fn reconcile_inbound_persona_event_blocking( &arrival_relay_url, )? else { - return Ok(()); + return Ok(None); }; let conn = open_retention_db(&scope.db_path)?; - let outcome = retain_inbound_event( - &conn, - &RetainedEvent { - kind, - pubkey: event.pubkey.to_hex(), - d_tag: d_tag.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, - )?; - if outcome == InboundOutcome::Skipped { - return Ok(()); + let inbound_retained_event = RetainedEvent { + kind, + pubkey: event.pubkey.to_hex(), + d_tag: d_tag.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }; + // Managed-agent access changes can fail while stopping a runtime. Preflight + // the retention decision now, but do not advance the durable head until the + // local store has been saved; otherwise replay sees the failed revocation as + // already consumed and can never retry it. Persona/team paths retain first + // as before because they have no fallible runtime transition. + if kind == KIND_MANAGED_AGENT + && inbound_event_outcome(&conn, &inbound_retained_event)? == InboundOutcome::Skipped + { + return Ok(None); + } + if kind != KIND_MANAGED_AGENT + && retain_inbound_event(&conn, &inbound_retained_event)? == InboundOutcome::Skipped + { + return Ok(None); } + let mut runtime_refresh = None; match kind { KIND_PERSONA => { let mut personas = load_personas(&app)?; @@ -176,8 +263,65 @@ fn reconcile_inbound_persona_event_blocking( let managed_agent = inbound_managed_agent.ok_or_else(|| { "managed-agent content was not parsed before retention".to_string() })?; - apply_inbound_managed_agent(&mut agents, &d_tag, managed_agent); + let access_changed = apply_inbound_managed_agent(&mut agents, &d_tag, managed_agent); + if access_changed { + let record = agents + .iter_mut() + .find(|record| record.pubkey == d_tag) + .ok_or_else(|| format!("agent {d_tag} disappeared during inbound apply"))?; + match &record.backend { + crate::managed_agents::BackendKind::Local => { + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + let mut relay_urls = + crate::managed_agents::managed_agent_runtime_keys(&runtimes, &d_tag) + .into_iter() + .map(|key| key.relay_url) + .collect::>(); + if relay_urls.is_empty() && record.runtime_pid.is_some() { + relay_urls.push(crate::relay::effective_agent_relay_url( + &record.relay_url, + &crate::relay::relay_ws_url_with_override(&state), + )); + } + if !relay_urls.is_empty() { + crate::managed_agents::stop_managed_agent_process( + &app, + record, + &mut runtimes, + )?; + runtime_refresh = Some(InboundRuntimeRefresh::Local { + pubkey: d_tag.clone(), + relay_urls, + }); + } + } + crate::managed_agents::BackendKind::Provider { id, config } + if record.backend_agent_id.is_some() => + { + // Persist the unacknowledged policy transition in the + // same write as the narrowed policy. If the process + // exits before or during deployment, workspace apply + // can still recover it in every build. + record.provider_policy_pending = true; + runtime_refresh = Some(InboundRuntimeRefresh::Provider { + pubkey: d_tag.clone(), + provider_id: id.clone(), + config: config.clone(), + cached_binary_path: record.provider_binary_path.clone(), + agent_json: super::super::agents::build_deploy_payload( + &app, &state, record, + ), + }); + } + crate::managed_agents::BackendKind::Provider { .. } => {} + } + } save_managed_agents(&app, &agents)?; + let outcome = retain_inbound_event(&conn, &inbound_retained_event)?; + debug_assert_eq!(outcome, InboundOutcome::Applied); } _ => unreachable!("kind gated above"), } @@ -187,7 +331,7 @@ fn reconcile_inbound_persona_event_blocking( // land on disk silently, leaving the Agents tab stale until restart. let _ = app.emit("agents-data-changed", ()); - Ok(()) + Ok(runtime_refresh) } fn validate_inbound_persona_definition(persona: &AgentDefinition) -> Result<(), String> { @@ -409,8 +553,10 @@ fn apply_inbound_managed_agent( agents: &mut [ManagedAgentRecord], d_tag: &str, inbound: ManagedAgentEventContent, -) { +) -> bool { if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { + let previous_mode = local.respond_to; + let previous_allowlist = local.respond_to_allowlist.clone(); local.name = inbound.name; // Mirror of the slimmed writer (agent_event_content): a // definition-linked event omits the definition quad because those @@ -428,7 +574,14 @@ fn apply_inbound_managed_agent( local.parallelism = inbound.parallelism; local.respond_to = inbound.respond_to; local.respond_to_allowlist = inbound.respond_to_allowlist; + return super::super::agent_models::managed_agent_access_policy_changed( + previous_mode, + &previous_allowlist, + local.respond_to, + &local.respond_to_allowlist, + ); } + false } /// Merge an inbound kind:30176 team projection into the local set. diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index e65973f1493..e7834f8231d 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -188,6 +188,7 @@ fn local_agent() -> ManagedAgentRecord { config: serde_json::json!({ "api_key": "localproviderkey" }), }, backend_agent_id: Some("local-remote-id".to_string()), + provider_policy_pending: false, provider_binary_path: Some("/local/bin".to_string()), team_id: None, persona_team_dir: None, @@ -262,8 +263,9 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() { let content = crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap(); let mut agents = vec![local_agent()]; - apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content); + let access_changed = apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content); + assert!(access_changed, "Anyone must trigger a runtime refresh"); let a = &agents[0]; // Secrets / harness / runtime — every one preserved from the local record. assert_eq!( diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7bb..6d7a2e6264b 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -39,6 +39,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304b..7d3fd95ff34 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -626,6 +626,7 @@ pub async fn confirm_agent_snapshot_import( runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9de..5e8cea52e69 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -48,6 +48,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4dd..72bdca7de9c 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -31,6 +31,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d7..8315f39a362 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -579,6 +579,7 @@ pub async fn confirm_team_snapshot_import( runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: Some(imported_team.id.clone()), persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a5..a466228160a 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -206,6 +206,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: Some("t1".to_string()), persona_team_dir: None, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6f3f48f3a84..b00d61521bb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -47,6 +47,8 @@ mod util; pub mod webkit_rendering; use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; +#[doc(hidden)] +pub use commands::print_agent_access_owner_only_probe_if_requested; use commands::*; use deep_link::{ acknowledge_pending_community_deep_link, acknowledge_pending_entity_deep_link, diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index ebcc127683a..3606272e590 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2,6 +2,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if buzz_lib::print_agent_access_owner_only_probe_if_requested() { + return; + } + // Before anything else: WebKitGTK reads its rendering environment once at // process start, and this is the only point where the process is still // single threaded and no GTK object exists yet, which is what makes diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 416b0c76c9d..f70c714323e 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -193,6 +193,7 @@ mod tests { config: serde_json::json!({ "api_key": "sk-provider-secret" }), }, backend_agent_id: Some("remote-id".to_string()), + provider_policy_pending: false, provider_binary_path: Some("/path/to/binary".to_string()), team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073d..751452aa7be 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -389,6 +389,7 @@ mod tests { runtime_pid: None, backend: crate::managed_agents::types::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e59..fca15111d0a 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -47,6 +47,7 @@ fn minimal_record() -> ManagedAgentRecord { config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), }, backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear + provider_policy_pending: false, provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 62caffeb2e4..0e7070724d4 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -88,6 +88,7 @@ fn test_record() -> ManagedAgentRecord { runtime_pid: None, backend: crate::managed_agents::types::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521b..e53c9114ab7 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -255,6 +255,7 @@ fn record_with( runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -316,8 +317,6 @@ fn record_agent_command_bare_record_defaults() { assert_eq!(record_agent_command(&record, &[]), default_agent_command()); } -// ── try_record_agent_command ───────────────────────────────────────────────── - /// When the record carries a dangling (unknown) runtime id, `try_record_agent_command` /// must return `Err` containing "DANGLING_HARNESS_ID" — NEVER the buzz-agent default. /// This test would fail if the function silently fell back to `default_agent_command()`. diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809ce..ee18e554c30 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -64,6 +64,7 @@ fn record( runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226c..b2a56870c73 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -324,6 +324,7 @@ fn bare_record() -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6fd..67cdb5fbaf1 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -474,6 +474,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e1691575b11..a6a50540bbe 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -89,6 +89,7 @@ mod tests { runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index de396f45c0f..7a3ce35b036 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -247,7 +247,22 @@ pub async fn flush_active_pending_events( flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await } -async fn flush_pending_events_at( +pub fn active_pending_event( + app: &tauri::AppHandle, + state: &AppState, + kind: u32, + d_tag: &str, +) -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; + Ok( + crate::managed_agents::retention::get_retained_event(&conn, kind, &owner_pubkey, d_tag)? + .is_some_and(|event| event.pending_sync), + ) +} + +pub(crate) async fn flush_pending_events_at( db_path: &std::path::Path, state: &AppState, relay_url: &str, diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 0580b12ce21..682fbef62fa 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -31,6 +31,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff13..c0055109077 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1503,6 +1503,7 @@ mod tests { runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -1546,8 +1547,6 @@ mod tests { ); } - // ── provider-specific model fallback tests ──────────────────────────── - #[test] fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() { // The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL. diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f566..e6231bbe42b 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -261,21 +261,32 @@ pub enum InboundOutcome { /// pending row intact so the flush republishes and the relay resolves /// last-writer-wins. (A re-received echo at equal time is also a no-op.) /// - Inbound older: skip — nothing to change. -pub fn retain_inbound_event( +/// +/// Decide whether an inbound event is newer than the retained coordinate without +/// mutating retention. Callers that must update another durable store first use +/// this preflight, apply that store change, and only then commit with +/// [`retain_inbound_event`]. +pub fn inbound_event_outcome( conn: &Connection, event: &RetainedEvent, ) -> Result { let existing = get_retained_event(conn, event.kind, &event.pubkey, &event.d_tag)?; - - let apply = match &existing { - None => true, - Some(row) if event.created_at > row.created_at => true, + Ok(match existing { + None => InboundOutcome::Applied, + Some(row) if event.created_at > row.created_at => InboundOutcome::Applied, // Equal or older: skip. Equal time may collide with a pending local // edit, so we never clear its `pending_sync`; older is stale. - Some(_) => false, - }; + Some(_) => InboundOutcome::Skipped, + }) +} - if !apply { +pub fn retain_inbound_event( + conn: &Connection, + event: &RetainedEvent, +) -> Result { + let outcome = inbound_event_outcome(conn, event)?; + + if outcome == InboundOutcome::Skipped { return Ok(InboundOutcome::Skipped); } @@ -553,6 +564,37 @@ mod tests { } } + #[test] + fn inbound_preflight_does_not_consume_event_before_commit() { + let conn = test_db(); + let mut inbound = sample_event(); + inbound.pending_sync = false; + + assert_eq!( + inbound_event_outcome(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + assert!( + get_retained_event(&conn, inbound.kind, &inbound.pubkey, &inbound.d_tag) + .unwrap() + .is_none() + ); + // A failed store/runtime apply can replay the same head because the + // preflight did not advance retention. + assert_eq!( + inbound_event_outcome(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + assert_eq!( + inbound_event_outcome(&conn, &inbound).unwrap(), + InboundOutcome::Skipped + ); + } + #[test] fn retain_and_retrieve() { let conn = test_db(); diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 9836d983ed3..792a275b059 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -62,6 +62,7 @@ pub(super) fn fixture( runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 1ceeee372f1..20e02871eba 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -43,6 +43,7 @@ fn record() -> ManagedAgentRecord { runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76d..5073d9c4070 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -283,6 +283,7 @@ mod tests { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda97..d66e68979cb 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -190,6 +190,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, persona_team_dir: None, persona_name_in_team: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed0..3b0641cb677 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -125,6 +125,7 @@ impl AgentDefinition { runtime_pid: None, backend: BackendKind::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -196,6 +197,8 @@ impl ManagedAgentRecord { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayAgentInfo { pub pubkey: String, + #[serde(default)] + pub owner_pubkey: Option, pub name: String, pub agent_type: String, pub channels: Vec, @@ -245,13 +248,9 @@ pub struct ManagedAgentRecord { pub avatar_url: Option, pub acp_command: String, pub agent_command: String, - /// Explicit per-instance harness pin. `None` (the default) means inherit - /// the harness from the linked persona's `runtime`, so persona harness - /// edits propagate on the next spawn — mirroring the opt-in `model` - /// override. `Some` is set only when the user deliberately picks a harness - /// that diverges from the persona. Resolved via `effective_agent_command`; - /// `agent_command` above is the create-time snapshot kept for avatar/legacy - /// derivations and is not authoritative for spawn. + /// Explicit per-instance harness pin; `None` inherits the persona runtime. + /// The effective command is resolved at spawn; `agent_command` is a legacy + /// create-time snapshot. #[serde(default)] pub agent_command_override: Option, pub agent_args: Vec, @@ -321,6 +320,8 @@ pub struct ManagedAgentRecord { #[serde(default)] pub backend_agent_id: Option, #[serde(default)] + pub provider_policy_pending: bool, + #[serde(default)] pub provider_binary_path: Option, /// Installed team directory path (absolute). Set when agent was created from a team persona. #[serde( diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 1db7b9b5243..0ae584e4acd 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -442,6 +442,21 @@ fn managed_agent_record_without_key_deserializes_empty() { .expect("keyring-backed record without inline key should deserialize"); assert_eq!(record.private_key_nsec, ""); + assert!( + !record.provider_policy_pending, + "pre-pending stores must deserialize as acknowledged" + ); +} + +#[test] +fn pending_provider_policy_round_trips() { + let mut record = sample_agent_record(); + record.provider_policy_pending = true; + + let json = serde_json::to_string(&record).expect("serialize pending policy"); + let reloaded: ManagedAgentRecord = serde_json::from_str(&json).expect("reload pending policy"); + + assert!(reloaded.provider_policy_pending); } fn sample_agent_record() -> ManagedAgentRecord { diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c92..64c8df05a79 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -495,6 +495,15 @@ pub fn agents_from_events(events: &[Event]) -> Value { json!({ "agents": arr }) } +// ── kind:0 + kind:30177 managed-agent directory ──────────────────────────── + +mod agent_directory; +pub use agent_directory::{ + managed_agent_pubkeys_from_events, member_agent_channel_ids_from_events, + relay_agents_from_directory_events, relay_agents_from_managed_agent_events, + verified_agent_owners_from_profiles, +}; + // ── kind:13534 (relay membership list) ────────────────────────────────────── /// Convert a kind:13534 relay membership list to the relay members format. @@ -578,434 +587,4 @@ fn days_to_ymd(days: i64) -> (i64, u32, u32) { } #[cfg(test)] -mod tests { - use super::*; - use nostr::{EventBuilder, Keys, Kind, Tag}; - - /// Build a signed event for testing with the given kind, content, and tags. - fn ev(kind: u16, content: &str, tags: Vec>) -> Event { - let keys = Keys::generate(); - let parsed: Vec = tags - .into_iter() - .map(|t| Tag::parse(t).expect("parse tag")) - .collect(); - EventBuilder::new(Kind::from_u16(kind), content) - .tags(parsed) - .sign_with_keys(&keys) - .expect("sign") - } - - /// Build a kind:0 profile with a valid NIP-OA auth tag. - fn oa_profile_event(content: &str) -> (Event, String) { - let agent_keys = Keys::generate(); - let owner_keys = Keys::generate(); - let agent_pubkey = agent_keys.public_key(); - let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, "") - .expect("compute auth tag"); - let tag_values: Vec = serde_json::from_str(&tag_json).expect("parse auth tag json"); - let auth_tag = Tag::parse(tag_values).expect("parse auth tag"); - - let event = EventBuilder::new(Kind::Metadata, content) - .tags(vec![auth_tag]) - .sign_with_keys(&agent_keys) - .expect("sign"); - (event, owner_keys.public_key().to_hex()) - } - - #[test] - fn channel_info_minimal() { - let e = ev( - 39000, - "", - vec![ - vec!["d", "chan-uuid-1"], - vec!["name", "general"], - vec!["about", "main channel"], - vec!["t", "stream"], - vec!["public"], - ], - ); - let info = channel_info_from_event(&e, None, None).unwrap(); - assert_eq!(info.id, "chan-uuid-1"); - assert_eq!(info.name, "general"); - assert_eq!(info.description, "main channel"); - assert_eq!(info.channel_type, "stream"); - assert_eq!(info.visibility, "open"); - assert_eq!(info.member_count, 0); - assert!(info.is_member); - } - - #[test] - fn channel_info_private_when_visibility_tag_present() { - let e = ev( - 39000, - "", - vec![ - vec!["d", "u"], - vec!["name", "n"], - vec!["t", "forum"], - vec!["visibility", "private"], - vec!["ttl", "86400"], - ], - ); - let info = channel_info_from_event(&e, None, None).unwrap(); - assert_eq!(info.visibility, "private"); - assert_eq!(info.channel_type, "forum"); - assert_eq!(info.ttl_seconds, Some(86400)); - } - - #[test] - fn channel_info_open_when_neither_public_nor_private() { - // Neither tag present → open (matches NIP-29 default). - let e = ev( - 39000, - "", - vec![vec!["d", "u"], vec!["name", "n"], vec!["t", "forum"]], - ); - let info = channel_info_from_event(&e, None, None).unwrap(); - assert_eq!(info.visibility, "open"); - } - - #[test] - fn channel_info_dm_inferred_from_hidden_tag() { - // Fallback: relays without ["t", "dm"] still emit ["hidden"] for DMs. - let e = ev( - 39000, - "", - vec![vec!["d", "u"], vec!["name", "n"], vec!["hidden"]], - ); - let info = channel_info_from_event(&e, None, None).unwrap(); - assert_eq!(info.channel_type, "dm"); - } - - #[test] - fn channel_info_merges_summary() { - let chan = ev(39000, "", vec![vec!["d", "u"], vec!["name", "n"]]); - let summary = ev( - 40901, - r#"{"member_count": 7, "last_message_at": "2026-01-01T00:00:00Z"}"#, - vec![vec!["d", "u"]], - ); - let info = channel_info_from_event(&chan, Some(&summary), None).unwrap(); - assert_eq!(info.member_count, 7); - assert_eq!( - info.last_message_at.as_deref(), - Some("2026-01-01T00:00:00Z") - ); - } - - #[test] - fn channel_info_missing_d_errors() { - let e = ev(39000, "", vec![vec!["name", "n"]]); - assert!(channel_info_from_event(&e, None, None).is_err()); - } - - #[test] - fn channel_detail_basic() { - let e = ev( - 39000, - "", - vec![ - vec!["d", "uuid"], - vec!["name", "n"], - vec!["about", "desc"], - vec!["topic", "tt"], - vec!["purpose", "pp"], - vec!["t", "dm"], - vec!["visibility", "private"], - vec!["ttl", "86400"], - vec!["ttl_deadline", "2026-06-11T00:00:00Z"], - ], - ); - let d = channel_detail_from_event(&e).unwrap(); - assert_eq!(d.id, "uuid"); - assert_eq!(d.topic.as_deref(), Some("tt")); - assert_eq!(d.purpose.as_deref(), Some("pp")); - assert_eq!(d.channel_type, "dm"); - assert_eq!(d.visibility, "private"); - assert_eq!(d.ttl_seconds, Some(86400)); - assert_eq!(d.ttl_deadline.as_deref(), Some("2026-06-11T00:00:00Z")); - assert!(d.created_at.ends_with("Z")); - assert_eq!(d.created_by, e.pubkey.to_hex()); - } - - #[test] - fn channel_members_extracts_p_tags() { - let pk1 = "a".repeat(64); - let pk2 = "b".repeat(64); - let e = ev( - 39002, - "", - vec![ - vec!["d", "uuid"], - vec!["p", &pk1, "", "admin"], - vec!["p", &pk2], - // Duplicate must be deduped. - vec!["p", &pk1, "wss://x", "owner"], - ], - ); - let r = channel_members_from_event(&e).unwrap(); - assert_eq!(r.members.len(), 2); - assert_eq!(r.members[0].pubkey, pk1); - assert_eq!(r.members[0].role, "admin"); - assert!(r.members[0].joined_at.is_none()); - assert_eq!(r.members[1].role, "member"); // default - } - - #[test] - fn channel_members_missing_d_errors() { - let e = ev(39002, "", vec![]); - assert!(channel_members_from_event(&e).is_err()); - } - - #[test] - fn profile_info_parses_content() { - let e = ev( - 0, - r#"{"name":"alice","display_name":"Alice","picture":"http://x/a.png","about":"hi","nip05":"alice@x"}"#, - vec![], - ); - let p = profile_info_from_event(&e).unwrap(); - assert_eq!(p.display_name.as_deref(), Some("Alice")); - assert_eq!(p.avatar_url.as_deref(), Some("http://x/a.png")); - assert_eq!(p.about.as_deref(), Some("hi")); - assert_eq!(p.nip05_handle.as_deref(), Some("alice@x")); - assert_eq!(p.pubkey, e.pubkey.to_hex()); - assert!(p.owner_pubkey.is_none()); - } - - #[test] - fn profile_info_extracts_valid_nip_oa_owner() { - let (event, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); - let p = profile_info_from_event(&event).unwrap(); - - assert_eq!(p.owner_pubkey.as_deref(), Some(owner_pubkey.as_str())); - } - - #[test] - fn profile_info_falls_back_to_name() { - let e = ev(0, r#"{"name":"bob"}"#, vec![]); - let p = profile_info_from_event(&e).unwrap(); - assert_eq!(p.display_name.as_deref(), Some("bob")); - } - - #[test] - fn profile_info_invalid_json_errors() { - let e = ev(0, "not-json", vec![]); - assert!(profile_info_from_event(&e).is_err()); - } - - #[test] - fn users_batch_keeps_latest_and_reports_missing() { - let e1 = ev(0, r#"{"name":"old"}"#, vec![]); - // Same author, newer event with display_name. - let keys = Keys::generate(); - let e_old = EventBuilder::new(Kind::Metadata, r#"{"name":"old"}"#) - .custom_created_at(nostr::Timestamp::from(1000)) - .sign_with_keys(&keys) - .unwrap(); - let e_new = EventBuilder::new(Kind::Metadata, r#"{"display_name":"New"}"#) - .custom_created_at(nostr::Timestamp::from(2000)) - .sign_with_keys(&keys) - .unwrap(); - let pk = keys.public_key().to_hex(); - let other_pk = e1.pubkey.to_hex(); - - let missing_pk = "f".repeat(64); - let resp = users_batch_from_events( - &[e1, e_old, e_new], - &[pk.clone(), other_pk.clone(), missing_pk.clone()], - ); - assert_eq!(resp.profiles.len(), 2); - assert_eq!(resp.profiles[&pk].display_name.as_deref(), Some("New")); - assert_eq!(resp.missing, vec![missing_pk]); - } - - #[test] - fn users_batch_marks_valid_nip_oa_profiles_as_agents() { - let (agent, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); - let pubkey = agent.pubkey.to_hex(); - let resp = - users_batch_from_events(std::slice::from_ref(&agent), std::slice::from_ref(&pubkey)); - - assert!(resp.profiles[&pubkey].is_agent); - assert_eq!( - resp.profiles[&pubkey].owner_pubkey.as_deref(), - Some(owner_pubkey.as_str()) - ); - } - - #[test] - fn user_notes_builds_cursor_from_last() { - let e1 = ev(1, "first", vec![]); - let e2 = ev(1, "second", vec![]); - let r = user_notes_from_events(&[e1, e2]); - assert_eq!(r.notes.len(), 2); - assert_eq!(r.notes[0].content, "first"); - let cursor = r.next_cursor.expect("cursor"); - assert_eq!(cursor.before_id, r.notes[1].id); - } - - #[test] - fn user_notes_empty_has_no_cursor() { - let r = user_notes_from_events(&[]); - assert!(r.notes.is_empty()); - assert!(r.next_cursor.is_none()); - } - - #[test] - fn contact_list_preserves_tags_and_content() { - let pk = "1".repeat(64); - let e = ev(3, "rel-json", vec![vec!["p", &pk]]); - let r = contact_list_from_event(&e).unwrap(); - assert_eq!(r.content, "rel-json"); - assert_eq!(r.tags.len(), 1); - assert_eq!(r.tags[0], vec!["p".to_string(), pk]); - } - - #[test] - fn search_response_assigns_descending_scores() { - let e1 = ev(1, "one", vec![vec!["h", "chan"]]); - let e2 = ev(1, "two", vec![]); - let r = search_response_from_events(&[e1, e2]); - assert_eq!(r.found, 2); - assert!(r.hits[0].score > r.hits[1].score); - assert_eq!(r.hits[0].channel_id.as_deref(), Some("chan")); - assert!(r.hits[1].channel_id.is_none()); - } - - #[test] - fn search_response_single_hit_full_score() { - let e = ev(1, "only", vec![]); - let r = search_response_from_events(&[e]); - assert_eq!(r.hits.len(), 1); - assert_eq!(r.hits[0].score, 1.0); - } - - #[test] - fn agents_overwrites_pubkey_from_event_author() { - let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]); - let v = agents_from_events(std::slice::from_ref(&e)); - let arr = v.get("agents").and_then(Value::as_array).unwrap(); - assert_eq!(arr.len(), 1); - assert_eq!( - arr[0].get("pubkey").and_then(Value::as_str).unwrap(), - e.pubkey.to_hex() - ); - assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1")); - } - - #[test] - fn agents_handles_invalid_content() { - let e = ev(10100, "not-json", vec![]); - let v = agents_from_events(std::slice::from_ref(&e)); - let arr = v.get("agents").and_then(Value::as_array).unwrap(); - assert_eq!( - arr[0].get("pubkey").and_then(Value::as_str).unwrap(), - e.pubkey.to_hex() - ); - } - - #[test] - fn agents_default_sparse_agent_profiles_for_directory_parse() { - let e = ev( - 10100, - r#"{"channel_add_policy":"owner-only","display_name":"Scout"}"#, - vec![], - ); - let v = agents_from_events(std::slice::from_ref(&e)); - let agents = v.get("agents").cloned().unwrap(); - let parsed: Vec = - serde_json::from_value(agents).unwrap(); - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].pubkey, e.pubkey.to_hex()); - assert_eq!(parsed[0].name, "Scout"); - assert_eq!(parsed[0].agent_type, "agent"); - assert_eq!(parsed[0].channels, Vec::::new()); - assert_eq!(parsed[0].capabilities, Vec::::new()); - assert_eq!(parsed[0].status, "offline"); - assert_eq!(parsed[0].respond_to, None); - } - - #[test] - fn agents_preserves_public_respond_to_mode_for_directory_parse() { - let e = ev(10100, r#"{"name":"Scout","respond_to":"anyone"}"#, vec![]); - let v = agents_from_events(std::slice::from_ref(&e)); - let agents = v.get("agents").cloned().unwrap(); - let parsed: Vec = - serde_json::from_value(agents).unwrap(); - - assert_eq!(parsed.len(), 1); - assert_eq!( - parsed[0].respond_to, - Some(crate::managed_agents::RespondTo::Anyone) - ); - } - - #[test] - fn agents_preserves_allowlist_metadata_for_directory_parse() { - let e = ev( - 10100, - r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, - vec![], - ); - let v = agents_from_events(std::slice::from_ref(&e)); - let agents = v.get("agents").cloned().unwrap(); - let parsed: Vec = - serde_json::from_value(agents).unwrap(); - - assert_eq!(parsed.len(), 1); - assert_eq!( - parsed[0].respond_to, - Some(crate::managed_agents::RespondTo::Allowlist) - ); - assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]); - } - - #[test] - fn relay_members_dedupes_and_defaults_role() { - let pk1 = "a".repeat(64); - let pk2 = "b".repeat(64); - // Current relay format: ["member", pubkey, role] - let e = ev( - 13534, - "", - vec![ - vec!["member", &pk1, "owner"], - vec!["member", &pk2], - vec!["member", &pk1, "moderator"], // dupe — ignored - ], - ); - let v = relay_members_from_event(&e); - let arr = v.get("members").and_then(Value::as_array).unwrap(); - assert_eq!(arr.len(), 2); - assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("owner")); - assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); - } - - #[test] - fn relay_members_fallback_p_tags() { - let pk1 = "a".repeat(64); - let pk2 = "b".repeat(64); - // Legacy/fallback format: ["p", pubkey, relay_url?, role?] - let e = ev( - 13534, - "", - vec![vec!["p", &pk1, "", "admin"], vec!["p", &pk2]], - ); - let v = relay_members_from_event(&e); - let arr = v.get("members").and_then(Value::as_array).unwrap(); - assert_eq!(arr.len(), 2); - assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("admin")); - assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); - } - - #[test] - fn timestamp_to_iso_known_value() { - // 2021-01-01T00:00:00Z = 1609459200 - assert_eq!(timestamp_to_iso(1_609_459_200), "2021-01-01T00:00:00Z"); - // Epoch - assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z"); - } -} +mod tests; diff --git a/desktop/src-tauri/src/nostr_convert/agent_directory.rs b/desktop/src-tauri/src/nostr_convert/agent_directory.rs new file mode 100644 index 00000000000..28604de5e5f --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/agent_directory.rs @@ -0,0 +1,191 @@ +//! Conversion and verification for relay-discovered agents. + +use std::collections::{BTreeSet, HashMap}; + +use nostr::Event; + +use crate::managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo}; + +use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey, tags_named}; + +/// Collect valid agent pubkeys from kind:30177 `d` tags for follow-up relay +/// queries. Malformed tags are ignored so one hostile event cannot invalidate +/// the whole directory request. +pub fn managed_agent_pubkeys_from_events(events: &[Event]) -> std::collections::HashSet { + events + .iter() + .filter_map(|event| first_tag_value(event, "d")) + .filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok()) + .map(|pubkey| pubkey.to_hex()) + .collect() +} + +fn event_is_newer(candidate: &Event, previous: &Event) -> bool { + candidate.created_at > previous.created_at + || (candidate.created_at == previous.created_at && candidate.id < previous.id) +} + +fn relay_agents_from_legacy_events(events: &[Event]) -> Vec { + let mut latest: HashMap = HashMap::new(); + for event in events { + let pubkey = event.pubkey.to_hex(); + if latest + .get(&pubkey) + .is_none_or(|previous| event_is_newer(event, previous)) + { + latest.insert(pubkey, event); + } + } + + latest + .into_values() + .filter_map(|event| { + let value = agents_from_events(std::slice::from_ref(event)); + let mut agent: RelayAgentInfo = + serde_json::from_value(value.get("agents")?.as_array()?.first()?.clone()).ok()?; + // Legacy directory entries are not authenticated managed-policy + // coordinates, so they must not drive the live 30177 watcher. + agent.owner_pubkey = None; + // Channel membership is authoritative only in relay-signed kind:39002. + agent.channel_ids.clear(); + Some(agent) + }) + .collect() +} + +/// Merge self-authored kind:10100 runtime profiles with verified Desktop-managed +/// policy records. A verified managed coordinate reserves the agent identity even +/// when its current policy is malformed, so stale legacy permissions cannot win. +pub fn relay_agents_from_directory_events( + directory_events: &[Event], + managed_agent_events: &[Event], + profile_events: &[Event], +) -> Vec { + let verified_policies = latest_verified_managed_policies(managed_agent_events, profile_events); + let mut agents: HashMap = + relay_agents_from_legacy_events(directory_events) + .into_iter() + .map(|agent| (agent.pubkey.clone(), agent)) + .collect(); + for agent_pubkey in verified_policies.keys() { + agents.remove(agent_pubkey); + } + for (agent_pubkey, event) in verified_policies { + if let Some(agent) = relay_agent_from_managed_policy(&agent_pubkey, event) { + agents.insert(agent_pubkey, agent); + } + } + + let mut agents: Vec<_> = agents.into_values().collect(); + agents.sort_by(|left, right| left.name.cmp(&right.name)); + agents +} + +/// Resolve each agent's owner from its latest signed NIP-OA profile. +pub fn verified_agent_owners_from_profiles(events: &[Event]) -> HashMap { + let mut latest_profiles: HashMap = HashMap::new(); + for profile in events { + let agent_pubkey = profile.pubkey.to_hex(); + if latest_profiles + .get(&agent_pubkey) + .is_none_or(|previous| event_is_newer(profile, previous)) + { + latest_profiles.insert(agent_pubkey, profile); + } + } + latest_profiles + .into_iter() + .filter_map(|(agent_pubkey, profile)| { + profile_valid_oa_owner_pubkey(profile).map(|owner| (agent_pubkey, owner)) + }) + .collect() +} + +fn latest_verified_managed_policies<'a>( + managed_agent_events: &'a [Event], + profile_events: &[Event], +) -> HashMap { + let verified_owners = verified_agent_owners_from_profiles(profile_events); + + let mut latest: HashMap = HashMap::new(); + for event in managed_agent_events { + let Some(agent_pubkey) = first_tag_value(event, "d") else { + continue; + }; + if verified_owners.get(agent_pubkey) != Some(&event.pubkey.to_hex()) { + continue; + } + if latest + .get(agent_pubkey) + .is_none_or(|previous| event_is_newer(event, previous)) + { + latest.insert(agent_pubkey.to_string(), event); + } + } + latest +} + +fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option { + let content = managed_agent_content_from_event(event).ok()?; + Some(RelayAgentInfo { + pubkey: agent_pubkey.to_string(), + owner_pubkey: Some(event.pubkey.to_hex()), + name: content.name, + agent_type: "agent".to_string(), + channels: Vec::new(), + channel_ids: Vec::new(), + capabilities: Vec::new(), + status: "offline".to_string(), + respond_to: Some(content.respond_to), + respond_to_allowlist: content.respond_to_allowlist, + }) +} + +/// Build the relay agent directory from owner-authenticated managed-agent +/// records. A kind:30177 event is accepted only when its author matches the +/// owner cryptographically declared by the agent's latest kind:0 NIP-OA tag. +pub fn relay_agents_from_managed_agent_events( + managed_agent_events: &[Event], + profile_events: &[Event], +) -> Vec { + let mut agents: Vec<_> = latest_verified_managed_policies(managed_agent_events, profile_events) + .into_iter() + .filter_map(|(agent_pubkey, event)| relay_agent_from_managed_policy(&agent_pubkey, event)) + .collect(); + agents.sort_by(|left, right| left.name.cmp(&right.name)); + agents +} + +/// Build a pubkey-to-channel-id candidate map from relay-signed membership +/// events. Only p-tags explicitly marked with the `bot` role are agents. +pub fn member_agent_channel_ids_from_events( + events: &[Event], + relay_pubkey: &str, +) -> HashMap> { + let mut channel_ids: HashMap> = HashMap::new(); + for event in events { + if !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) { + continue; + } + let Some(channel_id) = first_tag_value(event, "d") else { + continue; + }; + for tag in tags_named(event, "p") { + let (Some(pubkey), Some(role)) = (tag.get(1), tag.get(3)) else { + continue; + }; + if role != "bot" || nostr::PublicKey::from_hex(pubkey).is_err() { + continue; + } + channel_ids + .entry(pubkey.clone()) + .or_default() + .insert(channel_id.to_string()); + } + } + + channel_ids + .into_iter() + .map(|(pubkey, ids)| (pubkey, ids.into_iter().collect())) + .collect() +} diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs new file mode 100644 index 00000000000..9401d19add4 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -0,0 +1,762 @@ +//! Tests for the Nostr conversion surface. + +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag}; + +/// Build a signed event for testing with the given kind, content, and tags. +fn ev(kind: u16, content: &str, tags: Vec>) -> Event { + let keys = Keys::generate(); + let parsed: Vec = tags + .into_iter() + .map(|t| Tag::parse(t).expect("parse tag")) + .collect(); + EventBuilder::new(Kind::from_u16(kind), content) + .tags(parsed) + .sign_with_keys(&keys) + .expect("sign") +} + +/// Build a kind:0 profile with a valid NIP-OA auth tag. +fn oa_profile_event(content: &str) -> (Event, String) { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); + let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, "") + .expect("compute auth tag"); + let tag_values: Vec = serde_json::from_str(&tag_json).expect("parse auth tag json"); + let auth_tag = Tag::parse(tag_values).expect("parse auth tag"); + + let event = EventBuilder::new(Kind::Metadata, content) + .tags(vec![auth_tag]) + .sign_with_keys(&agent_keys) + .expect("sign"); + (event, owner_keys.public_key().to_hex()) +} + +fn managed_agent_event( + owner_keys: &Keys, + agent_pubkey: &str, + name: &str, + respond_to: &str, + respond_to_allowlist: &[String], +) -> Event { + let content = serde_json::json!({ + "name": name, + "parallelism": 1, + "respond_to": respond_to, + "respond_to_allowlist": respond_to_allowlist, + }) + .to_string(); + EventBuilder::new(Kind::Custom(30177), content) + .tags([Tag::parse(["d", agent_pubkey]).expect("parse d tag")]) + .sign_with_keys(owner_keys) + .expect("sign managed-agent event") +} + +#[test] +fn channel_info_minimal() { + let e = ev( + 39000, + "", + vec![ + vec!["d", "chan-uuid-1"], + vec!["name", "general"], + vec!["about", "main channel"], + vec!["t", "stream"], + vec!["public"], + ], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.id, "chan-uuid-1"); + assert_eq!(info.name, "general"); + assert_eq!(info.description, "main channel"); + assert_eq!(info.channel_type, "stream"); + assert_eq!(info.visibility, "open"); + assert_eq!(info.member_count, 0); + assert!(info.is_member); +} + +#[test] +fn channel_info_private_when_visibility_tag_present() { + let e = ev( + 39000, + "", + vec![ + vec!["d", "u"], + vec!["name", "n"], + vec!["t", "forum"], + vec!["visibility", "private"], + vec!["ttl", "86400"], + ], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.visibility, "private"); + assert_eq!(info.channel_type, "forum"); + assert_eq!(info.ttl_seconds, Some(86400)); +} + +#[test] +fn channel_info_open_when_neither_public_nor_private() { + // Neither tag present → open (matches NIP-29 default). + let e = ev( + 39000, + "", + vec![vec!["d", "u"], vec!["name", "n"], vec!["t", "forum"]], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.visibility, "open"); +} + +#[test] +fn channel_info_dm_inferred_from_hidden_tag() { + // Fallback: relays without ["t", "dm"] still emit ["hidden"] for DMs. + let e = ev( + 39000, + "", + vec![vec!["d", "u"], vec!["name", "n"], vec!["hidden"]], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.channel_type, "dm"); +} + +#[test] +fn channel_info_merges_summary() { + let chan = ev(39000, "", vec![vec!["d", "u"], vec!["name", "n"]]); + let summary = ev( + 40901, + r#"{"member_count": 7, "last_message_at": "2026-01-01T00:00:00Z"}"#, + vec![vec!["d", "u"]], + ); + let info = channel_info_from_event(&chan, Some(&summary), None).unwrap(); + assert_eq!(info.member_count, 7); + assert_eq!( + info.last_message_at.as_deref(), + Some("2026-01-01T00:00:00Z") + ); +} + +#[test] +fn channel_info_missing_d_errors() { + let e = ev(39000, "", vec![vec!["name", "n"]]); + assert!(channel_info_from_event(&e, None, None).is_err()); +} + +#[test] +fn channel_detail_basic() { + let e = ev( + 39000, + "", + vec![ + vec!["d", "uuid"], + vec!["name", "n"], + vec!["about", "desc"], + vec!["topic", "tt"], + vec!["purpose", "pp"], + vec!["t", "dm"], + vec!["visibility", "private"], + vec!["ttl", "86400"], + vec!["ttl_deadline", "2026-06-11T00:00:00Z"], + ], + ); + let d = channel_detail_from_event(&e).unwrap(); + assert_eq!(d.id, "uuid"); + assert_eq!(d.topic.as_deref(), Some("tt")); + assert_eq!(d.purpose.as_deref(), Some("pp")); + assert_eq!(d.channel_type, "dm"); + assert_eq!(d.visibility, "private"); + assert_eq!(d.ttl_seconds, Some(86400)); + assert_eq!(d.ttl_deadline.as_deref(), Some("2026-06-11T00:00:00Z")); + assert!(d.created_at.ends_with("Z")); + assert_eq!(d.created_by, e.pubkey.to_hex()); +} + +#[test] +fn channel_members_extracts_p_tags() { + let pk1 = "a".repeat(64); + let pk2 = "b".repeat(64); + let e = ev( + 39002, + "", + vec![ + vec!["d", "uuid"], + vec!["p", &pk1, "", "admin"], + vec!["p", &pk2], + // Duplicate must be deduped. + vec!["p", &pk1, "wss://x", "owner"], + ], + ); + let r = channel_members_from_event(&e).unwrap(); + assert_eq!(r.members.len(), 2); + assert_eq!(r.members[0].pubkey, pk1); + assert_eq!(r.members[0].role, "admin"); + assert!(r.members[0].joined_at.is_none()); + assert_eq!(r.members[1].role, "member"); // default +} + +#[test] +fn channel_members_missing_d_errors() { + let e = ev(39002, "", vec![]); + assert!(channel_members_from_event(&e).is_err()); +} + +#[test] +fn profile_info_parses_content() { + let e = ev( + 0, + r#"{"name":"alice","display_name":"Alice","picture":"http://x/a.png","about":"hi","nip05":"alice@x"}"#, + vec![], + ); + let p = profile_info_from_event(&e).unwrap(); + assert_eq!(p.display_name.as_deref(), Some("Alice")); + assert_eq!(p.avatar_url.as_deref(), Some("http://x/a.png")); + assert_eq!(p.about.as_deref(), Some("hi")); + assert_eq!(p.nip05_handle.as_deref(), Some("alice@x")); + assert_eq!(p.pubkey, e.pubkey.to_hex()); + assert!(p.owner_pubkey.is_none()); +} + +#[test] +fn profile_info_extracts_valid_nip_oa_owner() { + let (event, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); + let p = profile_info_from_event(&event).unwrap(); + + assert_eq!(p.owner_pubkey.as_deref(), Some(owner_pubkey.as_str())); +} + +#[test] +fn profile_info_falls_back_to_name() { + let e = ev(0, r#"{"name":"bob"}"#, vec![]); + let p = profile_info_from_event(&e).unwrap(); + assert_eq!(p.display_name.as_deref(), Some("bob")); +} + +#[test] +fn profile_info_invalid_json_errors() { + let e = ev(0, "not-json", vec![]); + assert!(profile_info_from_event(&e).is_err()); +} + +#[test] +fn users_batch_keeps_latest_and_reports_missing() { + let e1 = ev(0, r#"{"name":"old"}"#, vec![]); + // Same author, newer event with display_name. + let keys = Keys::generate(); + let e_old = EventBuilder::new(Kind::Metadata, r#"{"name":"old"}"#) + .custom_created_at(nostr::Timestamp::from(1000)) + .sign_with_keys(&keys) + .unwrap(); + let e_new = EventBuilder::new(Kind::Metadata, r#"{"display_name":"New"}"#) + .custom_created_at(nostr::Timestamp::from(2000)) + .sign_with_keys(&keys) + .unwrap(); + let pk = keys.public_key().to_hex(); + let other_pk = e1.pubkey.to_hex(); + + let missing_pk = "f".repeat(64); + let resp = users_batch_from_events( + &[e1, e_old, e_new], + &[pk.clone(), other_pk.clone(), missing_pk.clone()], + ); + assert_eq!(resp.profiles.len(), 2); + assert_eq!(resp.profiles[&pk].display_name.as_deref(), Some("New")); + assert_eq!(resp.missing, vec![missing_pk]); +} + +#[test] +fn users_batch_marks_valid_nip_oa_profiles_as_agents() { + let (agent, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); + let pubkey = agent.pubkey.to_hex(); + let resp = users_batch_from_events(std::slice::from_ref(&agent), std::slice::from_ref(&pubkey)); + + assert!(resp.profiles[&pubkey].is_agent); + assert_eq!( + resp.profiles[&pubkey].owner_pubkey.as_deref(), + Some(owner_pubkey.as_str()) + ); +} + +#[test] +fn user_notes_builds_cursor_from_last() { + let e1 = ev(1, "first", vec![]); + let e2 = ev(1, "second", vec![]); + let r = user_notes_from_events(&[e1, e2]); + assert_eq!(r.notes.len(), 2); + assert_eq!(r.notes[0].content, "first"); + let cursor = r.next_cursor.expect("cursor"); + assert_eq!(cursor.before_id, r.notes[1].id); +} + +#[test] +fn user_notes_empty_has_no_cursor() { + let r = user_notes_from_events(&[]); + assert!(r.notes.is_empty()); + assert!(r.next_cursor.is_none()); +} + +#[test] +fn contact_list_preserves_tags_and_content() { + let pk = "1".repeat(64); + let e = ev(3, "rel-json", vec![vec!["p", &pk]]); + let r = contact_list_from_event(&e).unwrap(); + assert_eq!(r.content, "rel-json"); + assert_eq!(r.tags.len(), 1); + assert_eq!(r.tags[0], vec!["p".to_string(), pk]); +} + +#[test] +fn search_response_assigns_descending_scores() { + let e1 = ev(1, "one", vec![vec!["h", "chan"]]); + let e2 = ev(1, "two", vec![]); + let r = search_response_from_events(&[e1, e2]); + assert_eq!(r.found, 2); + assert!(r.hits[0].score > r.hits[1].score); + assert_eq!(r.hits[0].channel_id.as_deref(), Some("chan")); + assert!(r.hits[1].channel_id.is_none()); +} + +#[test] +fn search_response_single_hit_full_score() { + let e = ev(1, "only", vec![]); + let r = search_response_from_events(&[e]); + assert_eq!(r.hits.len(), 1); + assert_eq!(r.hits[0].score, 1.0); +} + +#[test] +fn agents_overwrites_pubkey_from_event_author() { + let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]); + let v = agents_from_events(std::slice::from_ref(&e)); + let arr = v.get("agents").and_then(Value::as_array).unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!( + arr[0].get("pubkey").and_then(Value::as_str).unwrap(), + e.pubkey.to_hex() + ); + assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1")); +} + +#[test] +fn agents_handles_invalid_content() { + let e = ev(10100, "not-json", vec![]); + let v = agents_from_events(std::slice::from_ref(&e)); + let arr = v.get("agents").and_then(Value::as_array).unwrap(); + assert_eq!( + arr[0].get("pubkey").and_then(Value::as_str).unwrap(), + e.pubkey.to_hex() + ); +} + +#[test] +fn agents_default_sparse_agent_profiles_for_directory_parse() { + let e = ev( + 10100, + r#"{"channel_add_policy":"owner-only","display_name":"Scout"}"#, + vec![], + ); + let v = agents_from_events(std::slice::from_ref(&e)); + let agents = v.get("agents").cloned().unwrap(); + let parsed: Vec = + serde_json::from_value(agents).unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].pubkey, e.pubkey.to_hex()); + assert_eq!(parsed[0].name, "Scout"); + assert_eq!(parsed[0].agent_type, "agent"); + assert_eq!(parsed[0].channels, Vec::::new()); + assert_eq!(parsed[0].capabilities, Vec::::new()); + assert_eq!(parsed[0].status, "offline"); + assert_eq!(parsed[0].respond_to, None); +} + +#[test] +fn agents_preserves_public_respond_to_mode_for_directory_parse() { + let e = ev(10100, r#"{"name":"Scout","respond_to":"anyone"}"#, vec![]); + let v = agents_from_events(std::slice::from_ref(&e)); + let agents = v.get("agents").cloned().unwrap(); + let parsed: Vec = + serde_json::from_value(agents).unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].respond_to, + Some(crate::managed_agents::RespondTo::Anyone) + ); +} + +#[test] +fn agents_preserves_allowlist_metadata_for_directory_parse() { + let e = ev( + 10100, + r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, + vec![], + ); + let v = agents_from_events(std::slice::from_ref(&e)); + let agents = v.get("agents").cloned().unwrap(); + let parsed: Vec = + serde_json::from_value(agents).unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]); +} + +#[test] +fn managed_agent_directory_accepts_only_the_verified_owner_policy() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let attacker_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let viewer_pubkey = "a".repeat(64); + + let auth_tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "") + .expect("compute auth tag"); + let auth_tag_values: Vec = + serde_json::from_str(&auth_tag_json).expect("parse auth tag json"); + let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .tags([Tag::parse(auth_tag_values).expect("parse auth tag")]) + .sign_with_keys(&agent_keys) + .expect("sign profile"); + let authentic = managed_agent_event( + &owner_keys, + &agent_pubkey, + "Codex", + "allowlist", + std::slice::from_ref(&viewer_pubkey), + ); + let forged = managed_agent_event(&attacker_keys, &agent_pubkey, "Fake Codex", "anyone", &[]); + + let agents = relay_agents_from_managed_agent_events( + &[forged, authentic], + std::slice::from_ref(&profile), + ); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].pubkey, agent_pubkey); + assert_eq!(agents[0].name, "Codex"); + assert_eq!( + agents[0].respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(agents[0].respond_to_allowlist, vec![viewer_pubkey]); +} + +#[test] +fn managed_agent_directory_rejects_agents_without_verified_owner_profiles() { + let owner_keys = Keys::generate(); + let unverified_agent_keys = Keys::generate(); + let agent_pubkey = unverified_agent_keys.public_key().to_hex(); + let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .sign_with_keys(&unverified_agent_keys) + .expect("sign profile"); + let managed = managed_agent_event(&owner_keys, &agent_pubkey, "Codex", "anyone", &[]); + + let agents = relay_agents_from_managed_agent_events( + std::slice::from_ref(&managed), + std::slice::from_ref(&profile), + ); + + assert!(agents.is_empty()); +} + +#[test] +fn managed_agent_directory_uses_the_latest_profile_head() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let auth_tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "") + .expect("compute auth tag"); + let auth_tag_values: Vec = + serde_json::from_str(&auth_tag_json).expect("parse auth tag json"); + let verified_profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .tags([Tag::parse(auth_tag_values).expect("parse auth tag")]) + .custom_created_at(nostr::Timestamp::from(10)) + .sign_with_keys(&agent_keys) + .expect("sign verified profile"); + let revoked_profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .custom_created_at(nostr::Timestamp::from(20)) + .sign_with_keys(&agent_keys) + .expect("sign revoked profile"); + let managed = managed_agent_event(&owner_keys, &agent_pubkey, "Codex", "anyone", &[]); + + let agents = relay_agents_from_managed_agent_events( + std::slice::from_ref(&managed), + &[verified_profile, revoked_profile], + ); + + assert!(agents.is_empty()); +} + +#[test] +fn managed_agent_candidates_use_only_relay_signed_bot_membership() { + let relay_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key().to_hex(); + let stranger = Keys::generate().public_key().to_hex(); + let general = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "family"]).expect("parse d tag"), + Tag::parse(["p", &agent_pubkey, "", "bot"]).expect("parse agent tag"), + Tag::parse(["p", &stranger, "", "member"]).expect("parse member tag"), + ]) + .sign_with_keys(&relay_keys) + .expect("sign membership"); + let forged = ev( + 39002, + "", + vec![vec!["d", "forged"], vec!["p", &agent_pubkey, "", "bot"]], + ); + + let channel_ids = + member_agent_channel_ids_from_events(&[forged, general], &relay_keys.public_key().to_hex()); + + assert_eq!( + channel_ids.get(&agent_pubkey), + Some(&vec!["family".to_string()]) + ); + assert!(!channel_ids.contains_key(&stranger)); +} + +#[test] +fn managed_agent_directory_query_pubkeys_reject_malformed_d_tags() { + let valid_pubkey = Keys::generate().public_key().to_hex(); + let valid = ev(30177, "{}", vec![vec!["d", &valid_pubkey]]); + let malformed = ev(30177, "{}", vec![vec!["d", "not-a-pubkey"]]); + + let pubkeys = managed_agent_pubkeys_from_events(&[malformed, valid]); + + assert_eq!(pubkeys, [valid_pubkey].into_iter().collect()); +} + +#[test] +fn relay_agent_directory_preserves_headless_profiles_and_prefers_verified_managed_policy() { + let owner_keys = Keys::generate(); + let managed_agent_keys = Keys::generate(); + let managed_pubkey = managed_agent_keys.public_key().to_hex(); + let headless_keys = Keys::generate(); + let headless_pubkey = headless_keys.public_key().to_hex(); + let viewer_pubkey = "a".repeat(64); + + let headless_profile = EventBuilder::new( + Kind::Custom(10100), + serde_json::json!({ + "name": "Headless", + "respond_to": "anyone", + "channel_ids": ["untrusted-channel"] + }) + .to_string(), + ) + .sign_with_keys(&headless_keys) + .expect("sign headless directory profile"); + let stale_managed_profile = EventBuilder::new( + Kind::Custom(10100), + serde_json::json!({ + "name": "Stale Codex", + "respond_to": "anyone" + }) + .to_string(), + ) + .sign_with_keys(&managed_agent_keys) + .expect("sign managed directory profile"); + + let auth_tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &managed_agent_keys.public_key(), "") + .expect("compute auth tag"); + let auth_tag_values: Vec = + serde_json::from_str(&auth_tag_json).expect("parse auth tag json"); + let managed_identity = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .tags([Tag::parse(auth_tag_values).expect("parse auth tag")]) + .sign_with_keys(&managed_agent_keys) + .expect("sign managed profile"); + let managed_policy = managed_agent_event( + &owner_keys, + &managed_pubkey, + "Codex", + "allowlist", + std::slice::from_ref(&viewer_pubkey), + ); + + let agents = relay_agents_from_directory_events( + &[headless_profile, stale_managed_profile], + std::slice::from_ref(&managed_policy), + std::slice::from_ref(&managed_identity), + ); + + assert_eq!(agents.len(), 2); + let headless = agents + .iter() + .find(|agent| agent.pubkey == headless_pubkey) + .expect("headless profile retained"); + assert_eq!( + headless.respond_to, + Some(crate::managed_agents::RespondTo::Anyone) + ); + assert!( + headless.channel_ids.is_empty(), + "claimed channel ids are not trusted" + ); + + let managed = agents + .iter() + .find(|agent| agent.pubkey == managed_pubkey) + .expect("managed profile retained"); + assert_eq!(managed.name, "Codex"); + assert_eq!( + managed.respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(managed.respond_to_allowlist, vec![viewer_pubkey]); +} + +#[test] +fn authenticated_malformed_managed_policy_does_not_fall_back_to_legacy_permissions() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let legacy = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"Stale","respond_to":"anyone"}"#, + ) + .sign_with_keys(&agent_keys) + .expect("sign legacy profile"); + let auth_tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "") + .expect("compute auth tag"); + let auth_tag_values: Vec = + serde_json::from_str(&auth_tag_json).expect("parse auth tag json"); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags([Tag::parse(auth_tag_values).expect("parse auth tag")]) + .sign_with_keys(&agent_keys) + .expect("sign profile"); + let malformed = EventBuilder::new( + Kind::Custom(30177), + r#"{"name":"Current","parallelism":1,"respond_to":"future-mode"}"#, + ) + .tags([Tag::parse(["d", &agent_pubkey]).expect("parse d tag")]) + .sign_with_keys(&owner_keys) + .expect("sign managed policy"); + + let agents = relay_agents_from_directory_events(&[legacy], &[malformed], &[profile]); + + assert!(agents.is_empty()); +} + +#[test] +fn relay_agent_directory_resolves_equal_timestamp_heads_by_event_id() { + let keys = Keys::generate(); + let timestamp = nostr::Timestamp::from(42); + let first = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"First","respond_to":"anyone"}"#, + ) + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .expect("sign first directory head"); + let second = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"Second","respond_to":"anyone"}"#, + ) + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .expect("sign second directory head"); + let expected_name = if first.id < second.id { + "First" + } else { + "Second" + }; + + let forward = relay_agents_from_directory_events(&[first.clone(), second.clone()], &[], &[]); + let reverse = relay_agents_from_directory_events(&[second, first], &[], &[]); + + assert_eq!(forward.len(), 1); + assert_eq!(reverse.len(), 1); + assert_eq!(forward[0].name, expected_name); + assert_eq!(reverse[0].name, expected_name); +} + +#[test] +fn forged_managed_policy_cannot_suppress_a_headless_directory_agent() { + let attacker_keys = Keys::generate(); + let targeted_agent_keys = Keys::generate(); + let targeted_pubkey = targeted_agent_keys.public_key().to_hex(); + let headless_keys = Keys::generate(); + let headless_pubkey = headless_keys.public_key().to_hex(); + let targeted_profile = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"Targeted","respond_to":"anyone"}"#, + ) + .sign_with_keys(&targeted_agent_keys) + .expect("sign targeted profile"); + let headless = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"Headless","respond_to":"anyone"}"#, + ) + .sign_with_keys(&headless_keys) + .expect("sign headless profile"); + let forged_policy = managed_agent_event( + &attacker_keys, + &targeted_pubkey, + "Codex", + "allowlist", + &["a".repeat(64)], + ); + + let agents = relay_agents_from_directory_events( + &[targeted_profile, headless], + std::slice::from_ref(&forged_policy), + &[], + ); + + assert_eq!(agents.len(), 2); + assert!(agents.iter().any(|agent| agent.pubkey == targeted_pubkey)); + assert!(agents.iter().any(|agent| agent.pubkey == headless_pubkey)); +} + +#[test] +fn relay_members_dedupes_and_defaults_role() { + let pk1 = "a".repeat(64); + let pk2 = "b".repeat(64); + // Current relay format: ["member", pubkey, role] + let e = ev( + 13534, + "", + vec![ + vec!["member", &pk1, "owner"], + vec!["member", &pk2], + vec!["member", &pk1, "moderator"], // dupe — ignored + ], + ); + let v = relay_members_from_event(&e); + let arr = v.get("members").and_then(Value::as_array).unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("owner")); + assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); +} + +#[test] +fn relay_members_fallback_p_tags() { + let pk1 = "a".repeat(64); + let pk2 = "b".repeat(64); + // Legacy/fallback format: ["p", pubkey, relay_url?, role?] + let e = ev( + 13534, + "", + vec![vec!["p", &pk1, "", "admin"], vec!["p", &pk2]], + ); + let v = relay_members_from_event(&e); + let arr = v.get("members").and_then(Value::as_array).unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("admin")); + assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); +} + +#[test] +fn timestamp_to_iso_known_value() { + // 2021-01-01T00:00:00Z = 1609459200 + assert_eq!(timestamp_to_iso(1_609_459_200), "2021-01-01T00:00:00Z"); + // Epoch + assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z"); +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 7accd54af69..53a39824c4e 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -160,8 +160,12 @@ with a TypeScript lookup table or an id comparison in a component. computer, including files, accounts, and connected tools"; remote names "the server it runs on, including any accounts and tools available there" — deliberately *not* the owner's files, which aren't theirs to describe on a - host they don't own. **An unknown location falls back to the local wording — - never hedge with "computer or server".** A remote host requires an + host they don't own. **For a persona-linked deployed agent, the profile Edit + dialog seeds access from the exact clicked instance and saves access through + `update_managed_agent`; persona behavior remains the definition default, but + must never bypass the instance command's stop, persist, publish, and restart + boundary.** An unknown location falls back to the local wording — never hedge + with "computer or server". A remote host requires an installed `buzz-backend-*` provider, and without one `WhereToRunSection` never renders, so "server" would name a concept the owner has never been shown; when it *is* remote they picked that host from the selector diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index de3d2b9e83b..0913582cabd 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -341,14 +341,10 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) { return useQuery({ queryKey: relayAgentsQueryKey, queryFn: listRelayAgents, - // Relay agent profiles (kind:10100) are near-static and the backing - // `list_relay_agents` command is an unfiltered relay query for the whole - // profile set — mounted on ~13 always-live surfaces (channel screen, - // members bar, mentions, sidebar, profile popovers), so a tight interval - // re-pulls the full set app-wide. This poll is also the ONLY refresh path: - // the `agents-data-changed` event fires only for local persona/team/managed - // reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we - // keep polling but at a relaxed cadence and pause it while backgrounded. + // Relay agent discovery is scoped to the viewer's relay-signed channel + // memberships, then resolves exact agent/profile/policy coordinates in + // protocol-sized batches. Polling remains the only refresh path for remote + // changes, so keep it relaxed and pause while backgrounded. refetchInterval, enabled: options?.enabled, ...agentsFocusRefetchPolicy, diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index cdb47bccd3a..8fbd7a3bfb1 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { pickProfileAgent } from "./pickProfileAgent.ts"; +import { + pickDirectProfileAgent, + pickProfileAgent, +} from "./pickProfileAgent.ts"; test("the shared profile target prefers the active persona instance", () => { const stopped = { @@ -18,3 +21,57 @@ test("the shared profile target prefers the active persona instance", () => { assert.equal(pickProfileAgent([stopped, running]), running); assert.equal(pickProfileAgent([running, stopped]), running); }); + +test("a direct-opened active instance is never redirected to a sibling", () => { + // "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an + // access edit on Tyler would target the sibling. + const sibling = { + name: "Alpha Sibling", + pubkey: "a".repeat(64), + status: "running", + }; + const clicked = { + name: "Tyler Agent", + pubkey: "b".repeat(64), + status: "running", + }; + + assert.equal(pickDirectProfileAgent(clicked, [sibling, clicked]), clicked); +}); + +test("a direct-opened inactive instance redirects to the active sibling", () => { + const historical = { + name: "Earlier Parity Agent", + pubkey: "a".repeat(64), + status: "stopped", + }; + const current = { + name: "Current Parity Agent", + pubkey: "b".repeat(64), + status: "running", + }; + + assert.equal( + pickDirectProfileAgent(historical, [historical, current]), + current, + ); +}); + +test("a direct-opened inactive instance with no active sibling stays put", () => { + const clicked = { + name: "Only Instance", + pubkey: "a".repeat(64), + status: "stopped", + }; + const otherStopped = { + name: "Another Stopped", + pubkey: "b".repeat(64), + status: "stopped", + }; + + assert.equal( + pickDirectProfileAgent(clicked, [clicked, otherStopped]), + clicked, + ); + assert.equal(pickDirectProfileAgent(clicked, []), clicked); +}); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts index c845145495b..cea746f10cd 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -16,3 +16,23 @@ export function pickProfileAgent(agents: readonly ManagedAgent[]) { return left.name.localeCompare(right.name); })[0]; } + +/** + * Resolve which instance a profile panel opened for `directAgent` should + * show, given every instance of the same persona. + * + * Access edits must target the exact instance the user clicked — resolving a + * running sidebar member to an alphabetically-earlier sibling would let a + * "tighten access" save widen the wrong agent. But when the clicked instance + * is inactive and the persona has an active instance elsewhere (an avatar on + * an old message from a retired instance), redirect to the active one so the + * panel matches the Agents library. + */ +export function pickDirectProfileAgent( + directAgent: ManagedAgent, + personaInstances: readonly ManagedAgent[], +) { + if (isManagedAgentActive(directAgent)) return directAgent; + const canonical = pickProfileAgent(personaInstances); + return canonical && isManagedAgentActive(canonical) ? canonical : directAgent; +} diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs new file mode 100644 index 00000000000..7a3643a3088 --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds"; +import { startRelayAgentPolicyRefresh } from "./useAgentsDataRefresh.ts"; + +const coordinates = [ + { ownerPubkey: "owner-a", agentPubkey: "agent-a" }, + { ownerPubkey: "owner-b", agentPubkey: "agent-b" }, +]; + +function event(pubkey, dTag) { + return { + id: "id", + pubkey, + created_at: 1, + kind: KIND_MANAGED_AGENT, + tags: dTag ? [["d", dTag]] : [], + content: "{}", + sig: "sig", + }; +} + +test("remote managed policy refresh accepts only exact authenticated coordinates", async () => { + let onEvent; + let filter; + let unsubscribeCalls = 0; + mock.method(relayClient, "subscribeLive", (nextFilter, listener) => { + filter = nextFilter; + onEvent = listener; + return Promise.resolve(() => { + unsubscribeCalls += 1; + return Promise.resolve(); + }); + }); + + let refreshes = 0; + const stop = startRelayAgentPolicyRefresh(coordinates, () => { + refreshes += 1; + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(filter, { + kinds: [KIND_MANAGED_AGENT], + authors: ["owner-a", "owner-b"], + "#d": ["agent-a", "agent-b"], + limit: 0, + }); + onEvent(event("owner-a", "agent-a")); + assert.equal(refreshes, 1); + + for (const irrelevant of [ + event("owner-x", "agent-a"), + event("owner-a", "agent-x"), + event("owner-a", "agent-b"), // authors×d cross-product + event("owner-a", null), + ]) { + onEvent(irrelevant); + } + assert.equal(refreshes, 1, "irrelevant coordinates must not refresh"); + + stop(); + assert.equal(unsubscribeCalls, 1); + mock.reset(); +}); + +test("stopping before subscription readiness still closes the live query", async () => { + let resolveSubscription; + let unsubscribeCalls = 0; + mock.method( + relayClient, + "subscribeLive", + () => + new Promise((resolve) => { + resolveSubscription = resolve; + }), + ); + + const stop = startRelayAgentPolicyRefresh(coordinates, () => {}); + stop(); + resolveSubscription(() => { + unsubscribeCalls += 1; + return Promise.resolve(); + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(unsubscribeCalls, 1); + mock.reset(); +}); + +test("no authenticated coordinates creates no global subscription", () => { + let subscriptions = 0; + mock.method(relayClient, "subscribeLive", () => { + subscriptions += 1; + return Promise.resolve(() => Promise.resolve()); + }); + startRelayAgentPolicyRefresh([], () => {})(); + assert.equal(subscriptions, 0); + mock.reset(); +}); diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 174fb9c92c1..0349618d114 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -2,6 +2,9 @@ import { listen } from "@tauri-apps/api/event"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayAgent, RelayEvent } from "@/shared/api/types"; +import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds"; import { managedAgentsQueryKey, personasQueryKey, @@ -10,18 +13,84 @@ import { } from "@/features/agents/hooks"; import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; -// Trailing-coalesce window: a backfill burst (up to 500 inbound events fed -// one-by-one through reconcile) fires one `agents-data-changed` per event. -// Collapsing them into a single invalidate after the burst settles keeps the -// refetch off React Query's implicit in-flight dedup and avoids redundant -// disk-read IPC. const COALESCE_MS = 200; +export const RELAY_POLICY_REFRESH_MIN_INTERVAL_MS = 5_000; + +export type RelayAgentPolicyCoordinate = { + agentPubkey: string; + ownerPubkey: string; +}; + +function eventDTag(event: RelayEvent): string | null { + return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null; +} + +/** + * Subscribe only to authenticated managed-agent coordinates already returned by + * the relay directory. The callback repeats the exact owner+d check because a + * combined Nostr filter admits the authors×d cross-product. + */ +export function startRelayAgentPolicyRefresh( + coordinates: RelayAgentPolicyCoordinate[], + onChange: () => void, + onError: (error: unknown) => void = (error) => { + console.warn("Couldn’t subscribe to managed agent policy updates", error); + }, +): () => void { + if (coordinates.length === 0) return () => {}; + + const allowed = new Set( + coordinates.map( + ({ ownerPubkey, agentPubkey }) => + `${ownerPubkey.toLowerCase()}:${agentPubkey.toLowerCase()}`, + ), + ); + const authors = [ + ...new Set(coordinates.map(({ ownerPubkey }) => ownerPubkey)), + ]; + const agentPubkeys = [ + ...new Set(coordinates.map(({ agentPubkey }) => agentPubkey)), + ]; + let disposed = false; + let unsubscribe: (() => Promise) | null = null; + void relayClient + .subscribeLive( + { + kinds: [KIND_MANAGED_AGENT], + authors, + "#d": agentPubkeys, + limit: 0, + }, + (event) => { + const dTag = eventDTag(event); + if ( + dTag && + allowed.has(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`) + ) { + onChange(); + } + }, + ) + .then((nextUnsubscribe) => { + if (disposed) void nextUnsubscribe(); + else unsubscribe = nextUnsubscribe; + }) + .catch(onError); + + return () => { + disposed = true; + void unsubscribe?.(); + }; +} + +function relayPolicyCoordinates(agents: RelayAgent[] | undefined) { + return (agents ?? []).flatMap((agent) => + agent.ownerPubkey + ? [{ agentPubkey: agent.pubkey, ownerPubkey: agent.ownerPubkey }] + : [], + ); +} -// Invalidate the live Agents-tab queries when the backend signals that inbound -// relay events changed the on-disk agents data. Mounted once at the app root -// with empty deps — invalidation is global and has no reason to be -// pubkey-scoped, so it must NOT live inside the pubkey-keyed `usePersonaSync` -// (re-registering per identity switch would leak a listener each time). export function useAgentsDataRefresh(): void { const queryClient = useQueryClient(); @@ -32,8 +101,6 @@ export function useAgentsDataRefresh(): void { void queryClient.invalidateQueries({ queryKey: managedAgentRuntimesQueryKey, }); - // Pair startup also changes the legacy managed-agent scalar status. - // Keep that cache synchronized for consumers outside pair-runtime UI. void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); }); @@ -47,10 +114,73 @@ export function useAgentsDataRefresh(): void { }, COALESCE_MS); }); + let policyStop = () => {}; + let policyTimer: ReturnType | undefined; + let policyDirty = false; + let policyRefreshInFlight = false; + let policyDisposed = false; + let coordinateKey = ""; + + const refreshPolicyDirectory = () => { + if (policyRefreshInFlight || policyTimer !== undefined) { + policyDirty = true; + return; + } + policyRefreshInFlight = true; + void queryClient + .invalidateQueries({ queryKey: relayAgentsQueryKey }) + .finally(() => { + policyRefreshInFlight = false; + if (policyDisposed) return; + policyTimer = setTimeout(() => { + policyTimer = undefined; + if (policyDirty) { + policyDirty = false; + refreshPolicyDirectory(); + } + }, RELAY_POLICY_REFRESH_MIN_INTERVAL_MS); + }); + }; + + const resubscribePolicy = () => { + const coordinates = relayPolicyCoordinates( + queryClient.getQueryData(relayAgentsQueryKey), + ); + const nextKey = coordinates + .map(({ ownerPubkey, agentPubkey }) => `${ownerPubkey}:${agentPubkey}`) + .sort() + .join("|"); + if (nextKey === coordinateKey) return; + coordinateKey = nextKey; + policyStop(); + policyStop = startRelayAgentPolicyRefresh( + coordinates, + refreshPolicyDirectory, + ); + }; + resubscribePolicy(); + const unsubscribeQueryCache = queryClient + .getQueryCache() + .subscribe((event) => { + if ( + event.query.queryKey.length === relayAgentsQueryKey.length && + event.query.queryKey.every( + (value: unknown, index: number) => + value === relayAgentsQueryKey[index], + ) + ) { + resubscribePolicy(); + } + }); + return () => { + policyDisposed = true; if (timer !== undefined) clearTimeout(timer); + if (policyTimer !== undefined) clearTimeout(policyTimer); void unlisten.then((fn) => fn()); void unlistenRuntime.then((fn) => fn()); + unsubscribeQueryCache(); + policyStop(); }; }, [queryClient]); } diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index 0dc12ddfd1e..a67b68cc7fd 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -108,3 +108,46 @@ test("startPersonaSync forwards its own relay as the event arrival relay", async mock.reset(); delete globalThis.window; }); + +test("startPersonaSync serializes inbound reconciliation in relay order", async () => { + const resolvers = []; + const invokedIds = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (_cmd, args) => { + invokedIds.push(JSON.parse(args.eventJson).id); + return new Promise((resolve) => resolvers.push(resolve)); + }, + }, + }; + + let onEvent; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "subscribeLive", (_filter, listener) => { + onEvent = listener; + return Promise.resolve(() => Promise.resolve()); + }); + + startPersonaSync("owner-pubkey", "wss://community.example", () => false); + await new Promise((resolve) => setImmediate(resolve)); + onEvent({ id: "broad", pubkey: "owner-pubkey", kind: KIND_MANAGED_AGENT }); + onEvent({ + id: "restricted", + pubkey: "owner-pubkey", + kind: KIND_MANAGED_AGENT, + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual( + invokedIds, + ["broad"], + "newer event waits for prior deployment", + ); + resolvers.shift()(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(invokedIds, ["broad", "restricted"]); + resolvers.shift()(); + + mock.reset(); + delete globalThis.window; +}); diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index f18194c5c6e..66ed679ad95 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -35,13 +35,19 @@ export function startPersonaSync( relayUrl: string, onCancelled: () => boolean, ): () => Promise { + // Reconcile in relay order. Managed-agent reconciliation can await a remote + // provider deployment after releasing the local store lock; firing commands + // independently lets an older broad policy finish after a newer restrictive + // one. One chain per owner/relay subscription makes the newest event the last + // deployment without serializing unrelated identities or communities. + let reconcileChain = Promise.resolve(); const reconcile = (event: RelayEvent) => { if (event.pubkey !== pubkey) return; - void reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl).catch( - (error) => { + reconcileChain = reconcileChain + .then(() => reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl)) + .catch((error) => { console.warn("[usePersonaSync] reconcile failed:", error); - }, - ); + }); }; // One-shot backfill of existing heads + tombstones (closes the fresh-start diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index b4606a0f686..06f41667b09 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -484,12 +484,6 @@ export function AgentDefinitionDialog({ const modelFieldVisible = runtime.trim().length > 0 || blankRuntimeModelProviderEditable; const isExplicitModelRequired = aiConfigurationMode === "custom"; - // Gate the provider requirement on the field's actual visibility, not the raw - // runtime capability. Codex/Claude hide the provider picker (they drive their - // own provider), so Customize must not require a provider there. But a - // runtime-less legacy/builtin definition still exposes the picker via - // blankRuntimeModelProviderEditable, so it must keep requiring a provider — - // otherwise Save could persist `provider: undefined` despite the visible field. const customAiPairSatisfied = agentAiConfigurationModeSatisfied( aiConfigurationMode, { provider, model }, @@ -739,7 +733,6 @@ export function AgentDefinitionDialog({ isPending={isPending} onCancel={() => handleOpenChange(false)} publishesCatalogUpdates={publishCatalogUpdatesOnSave && hasUserChanges} - submitBlockReason={null} submitLabel={submitLabel} /> ); diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx index 92428ad95cb..6f15c8d860c 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx @@ -6,7 +6,6 @@ type AgentDefinitionDialogFooterProps = { isPending: boolean; onCancel: () => void; publishesCatalogUpdates: boolean; - submitBlockReason: string | null; submitLabel: string; }; @@ -16,20 +15,11 @@ export function AgentDefinitionDialogFooter({ isPending, onCancel, publishesCatalogUpdates, - submitBlockReason, submitLabel, }: AgentDefinitionDialogFooterProps) { return (
- {submitBlockReason ? ( -

- {submitBlockReason} -

- ) : null} {publishesCatalogUpdates ? (

{ ); }); +test("incomplete Customize explains why Save remains disabled", () => { + assert.equal( + agentAiConfigurationSubmitBlockReason("custom", { + provider: "", + model: "", + }), + "Choose a provider to save custom AI configuration.", + ); + assert.equal( + agentAiConfigurationSubmitBlockReason("custom", { + provider: "anthropic", + model: "", + }), + "Choose a model to save custom AI configuration.", + ); + assert.equal( + agentAiConfigurationSubmitBlockReason( + "custom", + { provider: "", model: "" }, + false, + ), + "Choose a model to save custom AI configuration.", + ); + assert.equal( + agentAiConfigurationSubmitBlockReason("defaults", { + provider: "", + model: "", + }), + null, + ); +}); + test("Codex/Claude Customize needs only a model, not the hidden provider", () => { // needsProviderSelection=false → the intentionally hidden provider must not // gate Save (the create/edit "Save stays disabled" regression). diff --git a/desktop/src/features/agents/ui/agentAiConfigurationPolicy.ts b/desktop/src/features/agents/ui/agentAiConfigurationPolicy.ts index 897ad0e1f3b..d39797cda09 100644 --- a/desktop/src/features/agents/ui/agentAiConfigurationPolicy.ts +++ b/desktop/src/features/agents/ui/agentAiConfigurationPolicy.ts @@ -47,6 +47,21 @@ export function agentAiConfigurationPairForMode({ * runtime capability, so the gate never diverges from the visible picker. It * defaults to `true` so existing callers keep the provider+model requirement. */ +export function agentAiConfigurationSubmitBlockReason( + mode: AgentAiConfigurationMode, + pair: AgentAiConfigurationPair, + needsProviderSelection = true, +): string | null { + if ( + mode !== "custom" || + agentAiConfigurationModeSatisfied(mode, pair, needsProviderSelection) + ) + return null; + return needsProviderSelection && !pair.provider.trim() + ? "Choose a provider to save custom AI configuration." + : "Choose a model to save custom AI configuration."; +} + export function agentAiConfigurationModeSatisfied( mode: AgentAiConfigurationMode, pair: AgentAiConfigurationPair, diff --git a/desktop/src/features/agents/ui/agentProfileSyncWarning.ts b/desktop/src/features/agents/ui/agentProfileSyncWarning.ts index 91be216fd6b..914285df72b 100644 --- a/desktop/src/features/agents/ui/agentProfileSyncWarning.ts +++ b/desktop/src/features/agents/ui/agentProfileSyncWarning.ts @@ -6,6 +6,6 @@ export function showAgentProfileSyncWarning( ) { if (!profileSyncError) return; toast.warning( - `${agentName} was saved, but relay profile sync failed: ${profileSyncError}. The relay may still show the old name — restart the agent to retry the sync.`, + `${agentName} was saved locally, but relay sync failed: ${profileSyncError}. Remote users may still see the previous name or access policy until Buzz retries the sync.`, ); } diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index e850c347757..b786bf5573d 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -269,6 +269,36 @@ test("edit and duplicate seed the behavior group from a quad-bearing persona", ( ); }); +test("a linked instance overrides stale definition access in the edit dialog", () => { + const persona = { + id: "persona-instance-access", + displayName: "Shared", + avatarUrl: null, + systemPrompt: "Shared.", + runtime: null, + model: null, + provider: null, + isBuiltIn: false, + isActive: true, + respondTo: "owner-only", + respondToAllowlist: [], + parallelism: 2, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-02T00:00:00Z", + }; + + const state = editPersonaDialogState(persona, { + respondTo: "allowlist", + respondToAllowlist: ["c".repeat(64)], + }); + + assert.deepEqual(state.initialValues.behavior, { + respondTo: "allowlist", + respondToAllowlist: ["c".repeat(64)], + parallelism: 2, + }); +}); + test("a non-allowlist mode does not seed a stale allowlist into the dialog", () => { const state = editPersonaDialogState({ id: "persona-mode-flip", diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index a553182ce87..e09e647b9f4 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -104,7 +104,15 @@ function behaviorEntry( export function editPersonaDialogState( persona: AgentPersona, + accessSource?: Pick, ): PersonaDialogState { + const behaviorSource = accessSource + ? { + ...persona, + respondTo: accessSource.respondTo, + respondToAllowlist: accessSource.respondToAllowlist, + } + : persona; return { title: "Edit agent", description: "", @@ -123,7 +131,7 @@ export function editPersonaDialogState( // the dialog must therefore round-trip the existing values.) namePool: persona.namePool ?? [], envVars: persona.envVars ?? {}, - ...behaviorEntry(persona), + ...behaviorEntry(behaviorSource), }, }; } diff --git a/desktop/src/features/channels/ui/EditRespondToDialog.tsx b/desktop/src/features/channels/ui/EditRespondToDialog.tsx index d3c3df339bd..078a4c61676 100644 --- a/desktop/src/features/channels/ui/EditRespondToDialog.tsx +++ b/desktop/src/features/channels/ui/EditRespondToDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly"; import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning"; +import { showAgentProfileSyncWarning } from "@/features/agents/ui/agentProfileSyncWarning"; import { CreateAgentRespondToField, OWNER_ONLY_ACCESS_DISABLED_REASON, @@ -50,12 +51,13 @@ export function EditRespondToDialog({ async function handleSave() { if (!agent) return; - await updateMutation.mutateAsync({ + const result = await updateMutation.mutateAsync({ pubkey: agent.pubkey, respondTo, respondToAllowlist: respondTo === "allowlist" ? respondToAllowlist : undefined, }); + showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); onOpenChange(false); } diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index b375649292d..0257e52ebf9 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -206,33 +206,36 @@ export function MembersSidebarMemberCard({

)} {managedAgentRuntime || managedAgent ? ( - + + {managedAgentRuntime + ? agentCommunityAvailability(managedAgentRuntime) : managedAgent && isManagedAgentActive(managedAgent) - ? "default" - : "secondary" - } - > - {managedAgentRuntime - ? agentCommunityAvailability(managedAgentRuntime) - : managedAgent && isManagedAgentActive(managedAgent) - ? "Running" - : "Stopped"} - - ) : null} - {managedAgent ? ( - - {formatRespondToLabel(managedAgent)} - + ? "Running" + : "Stopped"} + + {managedAgent ? ( + + {formatRespondToLabel(managedAgent)} + + ) : null} +
) : null}
diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts index e900e123f52..6785640a7f6 100644 --- a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts @@ -1,6 +1,9 @@ import * as React from "react"; -import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; +import { + pickDirectProfileAgent, + pickProfileAgent, +} from "@/features/agents/lib/pickProfileAgent"; import { useUserProfileQuery } from "@/features/profile/hooks"; import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId"; @@ -11,6 +14,7 @@ export function useCanonicalManagedAgentProfile(input: { currentPubkey: string | undefined; managedAgents: readonly ManagedAgent[] | undefined; personaId: string | undefined; + preferDirectManagedAgent?: boolean; preserveRequestedInstance?: boolean; pubkey: string | undefined; }) { @@ -18,6 +22,7 @@ export function useCanonicalManagedAgentProfile(input: { currentPubkey, managedAgents, personaId, + preferDirectManagedAgent = false, preserveRequestedInstance = false, pubkey, } = input; @@ -48,13 +53,20 @@ export function useCanonicalManagedAgentProfile(input: { (agent) => agent.personaId === linkedPersonaId, ); }, [directManagedAgent, linkedPersonaId, managedAgents]); - const managedAgent = React.useMemo( - () => - preserveRequestedInstance && directManagedAgent - ? directManagedAgent - : (pickProfileAgent(personaInstances) ?? directManagedAgent), - [directManagedAgent, personaInstances, preserveRequestedInstance], - ); + const managedAgent = React.useMemo(() => { + if (directManagedAgent) { + if (preserveRequestedInstance) return directManagedAgent; + if (preferDirectManagedAgent) { + return pickDirectProfileAgent(directManagedAgent, personaInstances); + } + } + return pickProfileAgent(personaInstances) ?? directManagedAgent; + }, [ + directManagedAgent, + personaInstances, + preferDirectManagedAgent, + preserveRequestedInstance, + ]); return { linkedPersonaId, managedAgent, personaInstances }; } diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 998ea3232a8..7638b0cb367 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -187,7 +187,6 @@ export function UserProfilePanel({ requestedInstancePubkey && normalizePubkey(pubkey) === normalizePubkey(requestedInstancePubkey), ); - const personasQuery = usePersonasQuery(); const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); const { linkedPersonaId, managedAgent, personaInstances } = @@ -195,6 +194,7 @@ export function UserProfilePanel({ currentPubkey, managedAgents: managedAgentsQuery.data, personaId: persona?.id, + preferDirectManagedAgent: true, preserveRequestedInstance, pubkey, }); @@ -398,15 +398,17 @@ export function UserProfilePanel({ onClose, viewerIsOwner, }); - + const openResolvedPersonaEditor = React.useCallback(() => { + if (!resolvedPersona) return false; + setPersonaDialogState( + editPersonaDialogState(resolvedPersona, managedAgent), + ); + return true; + }, [managedAgent, resolvedPersona]); const handleEditAgent = React.useCallback(() => { - if (resolvedPersona) { - setPersonaDialogState(editPersonaDialogState(resolvedPersona)); - return; - } + if (openResolvedPersonaEditor()) return; setEditAgentOpen(true); - }, [resolvedPersona, setEditAgentOpen]); - + }, [openResolvedPersonaEditor, setEditAgentOpen]); const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = useProfileAgentDeletion({ channels: channelsQuery.data, @@ -545,10 +547,7 @@ export function UserProfilePanel({ ], ); - const handleEditPersona = React.useCallback(() => { - if (!resolvedPersona) return; - setPersonaDialogState(editPersonaDialogState(resolvedPersona)); - }, [resolvedPersona]); + const handleEditPersona = openResolvedPersonaEditor; const handleDuplicatePersona = React.useCallback(() => { if (!resolvedPersona) return; @@ -913,7 +912,7 @@ export function UserProfilePanel({ ? () => { setEditAgentOpen(false); setEditAgentFocus(undefined); - setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + openResolvedPersonaEditor(); } : undefined } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index c3ff723375c..0c983fa3e84 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -58,6 +58,8 @@ function persona(overrides = {}) { namePool: [], isBuiltIn: false, isActive: true, + respondTo: "owner-only", + respondToAllowlist: [], envVars: { NEW_KEY: "2" }, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", @@ -93,6 +95,42 @@ test("personaManagedAgentUpdate syncs edited persona identity to linked agent", }); }); +test("personaManagedAgentUpdate syncs definition access to the linked agent", () => { + assert.deepEqual( + personaManagedAgentUpdate( + agent({ respondTo: "anyone" }), + persona({ respondTo: "owner-only" }), + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + respondTo: "owner-only", + }, + ); + + assert.deepEqual( + personaManagedAgentUpdate( + agent({ respondTo: "anyone" }), + persona({ + respondTo: "allowlist", + respondToAllowlist: ["a".repeat(64)], + }), + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + respondTo: "allowlist", + respondToAllowlist: ["a".repeat(64)], + }, + ); +}); + test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { assert.equal( personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()), diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 16999816c36..be1a57c112e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -298,6 +298,22 @@ export function personaManagedAgentUpdate( hasChanges = true; } + // Definition edits expose the access policy in the same dialog as identity + // and runtime settings. Keep the exact linked instance in sync when the + // definition carries an explicit policy; otherwise the dialog reopens with + // the new value while the running agent and sidebar retain the old one. + if (persona.respondTo != null && persona.respondTo !== agent.respondTo) { + input.respondTo = persona.respondTo; + hasChanges = true; + } + if ( + persona.respondTo === "allowlist" && + !stringArrayEqual(persona.respondToAllowlist, agent.respondToAllowlist) + ) { + input.respondToAllowlist = [...persona.respondToAllowlist]; + hasChanges = true; + } + const runtimeChanged = options.previousPersona !== undefined && options.previousPersona.runtime !== persona.runtime; diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 3009076aa8a..4cde503fde5 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -102,6 +102,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { if (!agentsByPubkey.has(agent.pubkey)) { agentsByPubkey.set(agent.pubkey, { pubkey: agent.pubkey, + ownerPubkey: null, name: agent.name, agentType: agent.agentCommand, channels: [], diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 8eb626a81dd..038ae52714b 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -100,6 +100,7 @@ type RawSearchResponse = { type RawRelayAgent = { pubkey: string; + owner_pubkey?: string | null; name: string; agent_type: string; channels: string[]; @@ -109,7 +110,6 @@ type RawRelayAgent = { respond_to?: RelayAgent["respondTo"]; respond_to_allowlist?: string[]; }; - import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; export type RawManagedAgent = { pubkey: string; @@ -652,10 +652,10 @@ export async function createAuthEvent(input: { const eventJson = await invokeTauri("create_auth_event", input); return JSON.parse(eventJson) as RelayEvent; } - function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { return { pubkey: agent.pubkey, + ownerPubkey: agent.owner_pubkey ?? null, name: agent.name, agentType: agent.agent_type, channels: agent.channels, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 24ef6257832..dcf6d2e8bc7 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -266,9 +266,9 @@ export type RelayMember = { addedBy: string | null; createdAt: string; }; - export type RelayAgent = { pubkey: string; + ownerPubkey: string | null; name: string; agentType: string; channels: string[]; diff --git a/desktop/tests/e2e/agent-access-warning.spec.ts b/desktop/tests/e2e/agent-access-warning.spec.ts index 709c5fbbc62..adb9c6d58ab 100644 --- a/desktop/tests/e2e/agent-access-warning.spec.ts +++ b/desktop/tests/e2e/agent-access-warning.spec.ts @@ -46,28 +46,30 @@ test("open agent access explains the available access before save", async ({ name: "Hack Day Helper", status: "running", channelNames: ["general"], - respondTo: "owner-only", + respondTo: "anyone", }, ], }); await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("channel-members-trigger").click(); + const accessBadge = page.getByTestId( + `sidebar-managed-agent-respond-to-${agent.pubkey}`, + ); + await expect(accessBadge).toBeVisible(); + await expect(accessBadge).toHaveText("Anyone"); await openAgentAccessDialog(page, agent.pubkey); const accessSelect = page.getByTestId("agent-respond-to-select"); - await expect(accessSelect).toHaveValue("owner-only"); - await expect(page.getByTestId("agent-access-warning")).toHaveCount(0); + await expect(accessSelect).toHaveValue("anyone"); const saveAccess = page.getByRole("button", { name: "Save access" }); await expect(saveAccess).toBeVisible(); const commandsBeforeSave = await page.evaluate( () => window.__BUZZ_E2E_COMMAND_LOG__?.length ?? 0, ); - await accessSelect.selectOption("anyone"); - const warning = page.getByTestId("agent-access-warning"); - await expect(warning).toBeVisible(); - await expect(warning).toContainText( - "Anyone can use this agent to access your computer, including files, accounts, and connected tools.", - ); + await accessSelect.selectOption("owner-only"); + await expect(page.getByTestId("agent-access-warning")).toHaveCount(0); await waitForAnimations(page); await page @@ -88,17 +90,19 @@ test("open agent access explains the available access before save", async ({ (entry) => entry.command === "update_managed_agent" && (entry.payload as { input?: { respondTo?: string } })?.input - ?.respondTo === "anyone", + ?.respondTo === "owner-only", ); }, commandsBeforeSave), ) .toBe(true); + await expect(accessBadge).toHaveText("Only me"); await openAgentAccessDialog(page, agent.pubkey); - await expect(accessSelect).toHaveValue("anyone"); + await expect(accessSelect).toHaveValue("owner-only"); // Selected people narrows the audience but not the access, so the warning // persists with its own audience phrase. await accessSelect.selectOption("allowlist"); + const warning = page.getByTestId("agent-access-warning"); await expect(warning).toBeVisible(); await expect(warning).toContainText( "Selected people can use this agent to access your computer, including files, accounts, and connected tools.", @@ -124,6 +128,134 @@ test("open agent access explains the available access before save", async ({ await expect(warning).toHaveCount(0); }); +test("full agent editor tightens the exact sidebar agent instance", async ({ + page, +}) => { + const agent = TEST_IDENTITIES.tyler; + const preferredSiblingPubkey = "d".repeat(64); + const personaId = "shared-sidebar-agent"; + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + availability: "available", + command: "goose", + default_args: [], + id: "goose", + install_hint: "", + label: "Goose", + mcp_command: "", + }, + ], + globalAgentConfig: { + env_vars: { ANTHROPIC_API_KEY: "sk-ant-test-key" }, + model: "claude-opus-4-5", + preferred_runtime: "goose", + provider: "anthropic", + }, + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Tyler Agent", + personaId, + status: "running", + channelNames: ["general"], + respondTo: "anyone", + }, + { + pubkey: preferredSiblingPubkey, + name: "Preferred Sibling", + personaId, + status: "running", + channelNames: [], + respondTo: "anyone", + }, + ], + personas: [ + { + displayName: "Shared Sidebar Agent", + id: personaId, + isActive: true, + respondTo: "anyone", + runtime: "goose", + systemPrompt: "Test exact instance editing.", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("channel-members-trigger").click(); + const accessBadge = page.getByTestId( + `sidebar-managed-agent-respond-to-${agent.pubkey}`, + ); + await expect(accessBadge).toHaveText("Anyone"); + + await page.getByTestId(`sidebar-member-${agent.pubkey}`).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await page.getByTestId("user-profile-edit-agent").click(); + const dialog = page.getByRole("dialog", { name: "Edit agent" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Advanced" }).click(); + await choosePersonaAccess(page, "Only me (default)"); + await dialog.getByRole("tab", { name: "Customize for this agent" }).click(); + const saveChanges = dialog.getByRole("button", { name: "Save changes" }); + await expect(saveChanges).toBeEnabled(); + await saveChanges.click(); + await expect(dialog).not.toBeVisible(); + + const updateCommand = await page.evaluate( + (pubkey) => + window.__BUZZ_E2E_COMMAND_LOG__?.findLast( + (entry) => + entry.command === "update_managed_agent" && + (entry.payload as { input?: { pubkey?: string } })?.input?.pubkey === + pubkey, + ), + agent.pubkey, + ); + expect(updateCommand?.payload).toMatchObject({ + input: { pubkey: agent.pubkey, respondTo: "owner-only" }, + }); + expect(updateCommand?.payload).not.toMatchObject({ + input: { pubkey: preferredSiblingPubkey }, + }); + + await page.getByTestId("channel-members-trigger").click(); + await expect(accessBadge).toHaveText("Only me"); + + // The definition still says "anyone" after the instance-only save above. + // Reopening the same linked agent for an unrelated prompt edit must seed + // access from the exact instance, or the submit silently widens it again. + await page.getByTestId(`sidebar-member-${agent.pubkey}`).click(); + await page.getByTestId("user-profile-edit-agent").click(); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Advanced" }).click(); + await expect(page.locator("#agent-respond-to")).toHaveText( + "Only me (default)", + ); + await page + .locator("#persona-system-prompt") + .fill("Test unrelated prompt editing after tightening access."); + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog).not.toBeVisible(); + + const unrelatedEditCommand = await page.evaluate( + (pubkey) => + window.__BUZZ_E2E_COMMAND_LOG__?.findLast( + (entry) => + entry.command === "update_managed_agent" && + (entry.payload as { input?: { pubkey?: string } })?.input?.pubkey === + pubkey, + ), + agent.pubkey, + ); + expect(unrelatedEditCommand?.payload).toMatchObject({ + input: { pubkey: agent.pubkey }, + }); + expect(unrelatedEditCommand?.payload).not.toMatchObject({ + input: { respondTo: "anyone" }, + }); +}); + test("a provider-backed agent's warning names the server, not this computer", async ({ page, }) => { From edc4a09aaa41c29e2495a28247c895febaf6587d Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 17 Aug 2026 09:56:34 -0700 Subject: [PATCH 03/16] feat(workflows): add responsive library card actions (#6008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** improvement **User Impact:** Users can scan what each workflow does and trigger, edit, duplicate, enable, disable, or delete it directly from the library. **Problem:** The workflow list buried common actions and did not expose each automation's trigger-to-action shape at a glance. **Solution:** Add a responsive workflow library with a persistent create tile, compact trigger/action diagrams, prominent workflow titles with supporting descriptions, and shared card actions while preserving existing detail, editor, and run-history entry points. Card toggles refresh both list and open-detail caches so status and definition stay consistent.
File changes **desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx** Adds a shared card menu for trigger, edit, duplicate, enable/disable, and delete actions. **desktop/src/features/workflows/ui/WorkflowCard.tsx** Reworks cards around the prototype's visual hierarchy: color-coded trigger, action flow, sentence-case eyebrow, prominent title, supporting description, status, channel, and update date without a footer clock icon. **desktop/src/features/workflows/ui/WorkflowsView.tsx** Adds the responsive grid, create tile, mutation wiring, and list/detail cache invalidation. Container breakpoints keep cards two-across at medium widths and three-across in the 1280px desktop layout. **desktop/src/features/workflows/ui/workflowDefinition.ts** Adds immutable enabled-state updates plus narrow trigger and first-action readers used only to select card icons. **desktop/src/features/workflows/ui/workflowDefinition.test.mjs** Covers neutral icon selection, enabled-state immutability, and status presentation. **desktop/tests/e2e/workflows.spec.ts** Covers the create tile, title/description hierarchy, selected-card enable/disable consistency, and deterministic narrow/medium/wide captures while retaining existing action coverage.
## Reproduction steps 1. Open **Workflows** and confirm the create tile stays first as cards flow from one to three columns with available width. 2. Confirm each card shows a sentence-case trigger eyebrow, prominent workflow title, supporting description when present, status, channel, and update date without a clock icon. 3. Open a card's overflow menu and trigger, edit, duplicate, enable/disable, or delete the workflow. 4. Leave the detail panel open while toggling and confirm its badge and JSON definition update with the card. ## Screenshots Real built E2E UI with representative workflow data at three viewport sizes. ### Narrow — 800 × 720 ![Workflow library at 800 by 720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-narrow-482d1b4c8.png) ### Medium — 1024 × 720 ![Workflow library at 1024 by 720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-medium-482d1b4c8.png) ### Wide — 1280 × 720 ![Workflow library at 1280 by 720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-482d1b4c8.png) ### Card actions ![Workflow library actions at 1280 by 720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-actions-482d1b4c8.png) --------- Signed-off-by: Taylor Ho --- crates/buzz-cli/src/commands/workflows.rs | 17 +- .../src/handlers/command_executor.rs | 274 +++++++++++++++++- crates/buzz-sdk/src/builders.rs | 8 +- desktop/src-tauri/src/commands/workflows.rs | 32 +- .../src-tauri/src/commands/workflows_tests.rs | 4 + desktop/src-tauri/src/events.rs | 45 +-- desktop/src-tauri/src/events/workflows.rs | 50 ++++ desktop/src/features/workflows/hooks.ts | 7 +- .../workflows/ui/WorkflowActionsMenu.tsx | 105 +++++++ .../features/workflows/ui/WorkflowCard.tsx | 205 +++++++------ .../features/workflows/ui/WorkflowDialog.tsx | 5 +- .../features/workflows/ui/WorkflowsView.tsx | 217 ++++++++------ .../workflows/ui/workflowDefinition.test.mjs | 57 ++++ .../workflows/ui/workflowDefinition.ts | 34 +++ desktop/src/shared/api/tauriWorkflows.ts | 4 + desktop/src/shared/api/workflowTypes.ts | 1 + desktop/src/testing/e2eBridge.ts | 13 + desktop/tests/e2e/workflows.spec.ts | 197 ++++++++++++- desktop/tests/helpers/bridge.ts | 1 + 19 files changed, 1038 insertions(+), 238 deletions(-) create mode 100644 desktop/src-tauri/src/events/workflows.rs create mode 100644 desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx create mode 100644 desktop/src/features/workflows/ui/workflowDefinition.test.mjs diff --git a/crates/buzz-cli/src/commands/workflows.rs b/crates/buzz-cli/src/commands/workflows.rs index 2786d2c5088..0028dfc7663 100644 --- a/crates/buzz-cli/src/commands/workflows.rs +++ b/crates/buzz-cli/src/commands/workflows.rs @@ -126,8 +126,21 @@ pub async fn cmd_update_workflow( let wf_uuid = parse_uuid(workflow_id)?; let yaml_definition = read_or_stdin(yaml)?; - let builder = buzz_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition) - .map_err(sdk_err)?; + let filter = serde_json::json!({ + "kinds": [30620], + "#d": [workflow_id] + }); + let resp = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&resp).unwrap_or_default(); + let expected_revision = events + .first() + .and_then(|event| event.get("id")) + .and_then(|id| id.as_str()) + .ok_or_else(|| CliError::NotFound(format!("workflow {workflow_id} not found")))?; + + let builder = + buzz_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition, expected_revision) + .map_err(sdk_err)?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 29abe9f27d4..d8569a7a86d 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -100,19 +100,18 @@ enum PersistResult { /// operations (open_dm, hide_dm, update_approval, upsert_workflow). #[datastore_span(name = "persist_command_event", system = "postgresql")] async fn persist_command_event( - state: &Arc, + db: &buzz_db::Db, tenant: &TenantContext, event: &Event, channel_id_override: Option, ) -> Result { let channel_id = channel_id_override.or_else(|| extract_channel_id(event)); - let mut tx = state - .db + let mut tx = db .begin_transaction() .await .map_err(|e| IngestError::Internal(format!("error: begin transaction: {e}")))?; - buzz_deletion::store(&state.db) + buzz_deletion::store(db) .guard_transaction(&mut tx, tenant.community()) .await .map_err(|error| { @@ -188,10 +187,28 @@ async fn persist_command_event( .map_err(|e| IngestError::Internal(format!("error: query event coordinate: {e}")))?; let incoming_id = event.id.as_bytes().as_slice(); + if existing + .as_ref() + .is_some_and(|(_, existing_id)| existing_id.as_slice() == incoming_id) + { + return Ok(PersistResult::Duplicate); + } + + let expected_revision = extract_tag(event, "expected-revision"); + validate_workflow_revision( + kind_i32, + expected_revision.as_deref(), + existing.as_ref().map(|(_, id)| id.as_slice()), + )?; if let Some((existing_ts, existing_id)) = existing { let dominated = created_at < existing_ts || (created_at == existing_ts && incoming_id >= existing_id.as_slice()); if dominated { + if kind_i32 == KIND_WORKFLOW_DEF as i32 && expected_revision.is_some() { + return Err(IngestError::Rejected( + "conflict: workflow update was superseded; refresh and try again".into(), + )); + } return Ok(PersistResult::Duplicate); } @@ -239,6 +256,41 @@ async fn persist_command_event( } } +fn validate_workflow_revision( + kind: i32, + expected_revision: Option<&str>, + existing_id: Option<&[u8]>, +) -> Result<(), IngestError> { + if kind != KIND_WORKFLOW_DEF as i32 { + return Ok(()); + } + + let expected_id = expected_revision + .map(|expected| { + let id = hex::decode(expected).map_err(|_| { + IngestError::Rejected("invalid: bad expected workflow revision".into()) + })?; + if id.len() != 32 { + return Err(IngestError::Rejected( + "invalid: bad expected workflow revision".into(), + )); + } + Ok(id) + }) + .transpose()?; + + match (expected_id.as_deref(), existing_id) { + (None, _) => Ok(()), + (Some(_), None) => Err(IngestError::Rejected( + "conflict: workflow revision does not exist".into(), + )), + (Some(expected), Some(existing)) if expected != existing => Err(IngestError::Rejected( + "conflict: workflow changed since it was loaded".into(), + )), + (Some(_), Some(_)) => Ok(()), + } +} + /// Extract all `p` tag values (hex pubkeys) from an event. fn extract_p_tags(event: &Event) -> Vec { event @@ -354,7 +406,7 @@ async fn handle_dm_open( } // Persist the command event (idempotency) — returns open transaction - let tx = match persist_command_event(state, tenant, event, None).await? { + let tx = match persist_command_event(&state.db, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -515,7 +567,7 @@ async fn handle_dm_add_member( } // Persist the command event — returns open transaction - let tx = match persist_command_event(state, tenant, event, None).await? { + let tx = match persist_command_event(&state.db, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -621,7 +673,7 @@ async fn handle_dm_hide( } // Persist the command event — returns open transaction - let tx = match persist_command_event(state, tenant, event, None).await? { + let tx = match persist_command_event(&state.db, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -758,7 +810,7 @@ async fn handle_workflow_def( let hash = compute_definition_hash(&definition_json_final); // Persist the command event — returns open transaction - let tx = match persist_command_event(state, tenant, event, None).await? { + let tx = match persist_command_event(&state.db, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -897,7 +949,7 @@ async fn handle_workflow_trigger( // Persist the command event under the workflow channel even though the // trigger event itself only carries the workflow UUID. Storing channel // triggers as global events leaks workflow IDs to unrelated relay members. - let tx = match persist_command_event(state, tenant, event, workflow.channel_id).await? { + let tx = match persist_command_event(&state.db, tenant, event, workflow.channel_id).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -1081,7 +1133,7 @@ async fn handle_approval_grant( check_approver_spec(&approval.approver_spec, &self_hex)?; // Persist the command event — returns open transaction - let tx = match persist_command_event(state, tenant, event, None).await? { + let tx = match persist_command_event(&state.db, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -1192,7 +1244,7 @@ async fn handle_approval_deny( check_approver_spec(&approval.approver_spec, &self_hex)?; // Persist the command event — returns open transaction - let tx = match persist_command_event(state, tenant, event, None).await? { + let tx = match persist_command_event(&state.db, tenant, event, None).await? { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -1385,3 +1437,203 @@ async fn resume_workflow_after_approval( .finalize_run(community_id, run_id, result, existing_trace) .await; } + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + async fn persistence_test_context() -> (buzz_db::Db, TenantContext) { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let pool = sqlx::PgPool::connect(&url) + .await + .expect("connect workflow persistence test database"); + let db = buzz_db::Db::from_pool(pool); + db.migrate() + .await + .expect("migrate workflow persistence test database"); + let host = format!("workflow-cas-{}.example", Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("create workflow persistence test community") + .id; + (db, TenantContext::resolved(community, host)) + } + + fn workflow_event( + keys: &Keys, + workflow_id: Uuid, + created_at: u64, + expected_revision: Option<&str>, + name: &str, + ) -> Event { + let workflow_id = workflow_id.to_string(); + let channel_id = Uuid::new_v4().to_string(); + let mut tags = vec![ + Tag::parse(["d", workflow_id.as_str()]).expect("d tag"), + Tag::parse(["h", channel_id.as_str()]).expect("h tag"), + ]; + if let Some(revision) = expected_revision { + tags.push(Tag::parse(["expected-revision", revision]).expect("revision tag")); + } + EventBuilder::new( + Kind::Custom(KIND_WORKFLOW_DEF as u16), + format!("name: {name}\ntrigger:\n on: message_posted\nsteps: []\n"), + ) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .expect("workflow event") + } + + fn rejection_message(result: Result<(), IngestError>) -> String { + match result { + Err(IngestError::Rejected(message)) => message, + Err(IngestError::AuthFailed(message)) => panic!("unexpected auth failure: {message}"), + Err(IngestError::Internal(message)) => panic!("unexpected internal failure: {message}"), + Ok(()) => panic!("expected revision validation to fail"), + } + } + + #[test] + fn workflow_revision_accepts_create_and_matching_update() { + let existing = [0x42; 32]; + assert!(validate_workflow_revision(KIND_WORKFLOW_DEF as i32, None, None).is_ok()); + assert!(validate_workflow_revision( + KIND_WORKFLOW_DEF as i32, + Some(&hex::encode(existing)), + Some(&existing), + ) + .is_ok()); + } + + #[test] + fn workflow_revision_rejects_stale_and_malformed_updates() { + let existing = [0x42; 32]; + let stale = [0x24; 32]; + assert_eq!( + rejection_message(validate_workflow_revision( + KIND_WORKFLOW_DEF as i32, + Some(&hex::encode(stale)), + Some(&existing), + )), + "conflict: workflow changed since it was loaded", + ); + assert!( + validate_workflow_revision(KIND_WORKFLOW_DEF as i32, None, Some(&existing)).is_ok(), + "tagless legacy workflow updates remain compatible during rollout", + ); + for malformed in ["not-hex", "42"] { + assert_eq!( + rejection_message(validate_workflow_revision( + KIND_WORKFLOW_DEF as i32, + Some(malformed), + Some(&existing), + )), + "invalid: bad expected workflow revision", + ); + assert_eq!( + rejection_message(validate_workflow_revision( + KIND_WORKFLOW_DEF as i32, + Some(malformed), + None, + )), + "invalid: bad expected workflow revision", + ); + } + } + + #[test] + fn workflow_revision_rejects_update_for_missing_coordinate() { + assert_eq!( + rejection_message(validate_workflow_revision( + KIND_WORKFLOW_DEF as i32, + Some(&hex::encode([0x42; 32])), + None, + )), + "conflict: workflow revision does not exist", + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_persistence_preserves_replays_and_rejects_dominated_cas_updates() { + let (db, tenant) = persistence_test_context().await; + let keys = Keys::generate(); + let workflow_id = Uuid::new_v4(); + let created_at = Timestamp::now().as_secs(); + let create = workflow_event(&keys, workflow_id, created_at, None, "create"); + + let PersistResult::Inserted(tx) = persist_command_event(&db, &tenant, &create, None) + .await + .expect("persist create") + else { + panic!("first create must insert"); + }; + tx.commit().await.expect("commit create"); + assert!(matches!( + persist_command_event(&db, &tenant, &create, None) + .await + .expect("replay create"), + PersistResult::Duplicate + )); + + let create_revision = create.id.to_hex(); + let mut updates = (0..64).map(|index| { + workflow_event( + &keys, + workflow_id, + created_at, + Some(&create_revision), + &format!("update-{index}"), + ) + }); + let update = updates + .find(|candidate| candidate.id.as_bytes() < create.id.as_bytes()) + .expect("find same-second update that wins NIP-33 ordering"); + let dominated_update = (64..256) + .map(|index| { + workflow_event( + &keys, + workflow_id, + created_at, + Some(&update.id.to_hex()), + &format!("update-{index}"), + ) + }) + .find(|candidate| candidate.id.as_bytes() > update.id.as_bytes()) + .expect("find same-second CAS-matching update dominated by current head"); + + let PersistResult::Inserted(tx) = persist_command_event(&db, &tenant, &update, None) + .await + .expect("persist update") + else { + panic!("matching update must insert"); + }; + tx.commit().await.expect("commit update"); + assert!(matches!( + persist_command_event(&db, &tenant, &update, None) + .await + .expect("replay update"), + PersistResult::Duplicate + )); + + let error = match persist_command_event(&db, &tenant, &dominated_update, None).await { + Err(error) => error, + Ok(_) => panic!("distinct dominated CAS update must not report duplicate success"), + }; + assert!(matches!( + error, + IngestError::Rejected(ref message) + if message == "conflict: workflow update was superseded; refresh and try again" + )); + } + + #[test] + fn revision_tag_does_not_change_other_command_kinds() { + assert!(validate_workflow_revision(KIND_DM_OPEN as i32, Some("not-hex"), None).is_ok()); + } +} diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 30311ddcf46..71c0f1e73db 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -1618,11 +1618,13 @@ pub fn build_workflow_update( channel_id: Uuid, workflow_id: Uuid, yaml: &str, + expected_revision: &str, ) -> Result { check_content(yaml, 64 * 1024)?; let tags = vec![ tag(&["d", &workflow_id.to_string()])?, tag(&["h", &channel_id.to_string()])?, + tag(&["expected-revision", expected_revision])?, ]; Ok(EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF as u16), yaml).tags(tags)) } @@ -3972,16 +3974,18 @@ mod tests { fn workflow_update_includes_h_tag() { let cid = uuid(); let wid = uuid(); - let ev = sign(build_workflow_update(cid, wid, "name: updated").unwrap()); + let revision = "a".repeat(64); + let ev = sign(build_workflow_update(cid, wid, "name: updated", &revision).unwrap()); assert_eq!(ev.kind.as_u16(), 30620); assert!(has_tag(&ev, "d", &wid.to_string())); assert!(has_tag(&ev, "h", &cid.to_string())); + assert!(has_tag(&ev, "expected-revision", &revision)); } #[test] fn workflow_update_rejects_oversized_yaml() { let big = "x".repeat(65 * 1024); - let err = build_workflow_update(uuid(), uuid(), &big).unwrap_err(); + let err = build_workflow_update(uuid(), uuid(), &big, &"a".repeat(64)).unwrap_err(); assert!(matches!(err, SdkError::ContentTooLarge { .. })); } diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index 25e02980fa7..2e77645f853 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -27,6 +27,8 @@ use crate::{ #[derive(Debug, Clone, Serialize, PartialEq)] pub struct WorkflowWire { pub id: String, + /// Event id of the current kind:30620 revision, used for conflict-protected updates. + pub revision: String, pub name: String, pub owner_pubkey: String, pub channel_id: Option, @@ -177,7 +179,8 @@ pub async fn create_workflow( state: State<'_, AppState>, ) -> Result { let workflow_id = uuid::Uuid::new_v4().to_string(); - let builder = events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition)?; + let builder = + events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition, None)?; let result = submit_event(builder, &state).await?; // The relay returns `webhook_secret` in the OK response message for @@ -195,6 +198,7 @@ pub async fn create_workflow( let now = now_secs(); let workflow = workflow_record( workflow_id, + result.event_id, Some(channel_id), current_pubkey_hex(&state)?, &yaml_definition, @@ -212,6 +216,7 @@ pub async fn create_workflow( pub async fn update_workflow( workflow_id: String, yaml_definition: String, + expected_revision: String, state: State<'_, AppState>, ) -> Result { // Find the channel id (and creation time) from the existing workflow event @@ -230,15 +235,24 @@ pub async fn update_workflow( let prior_event = prior .first() .ok_or_else(|| "workflow not found".to_string())?; + if prior_event.id.to_hex() != expected_revision { + return Err("workflow changed since it was loaded; refresh and try again".to_string()); + } let channel_id = tag_value(prior_event, "h").ok_or_else(|| "workflow not found".to_string())?; let created_at = prior_event.created_at.as_secs() as i64; - let builder = events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition)?; - submit_event(builder, &state).await?; + let builder = events::build_workflow_definition( + &workflow_id, + &channel_id, + &yaml_definition, + Some(&expected_revision), + )?; + let result = submit_event(builder, &state).await?; let updated_at = now_secs(); let workflow = workflow_record( workflow_id, + result.event_id, Some(channel_id), current_pubkey_hex(&state)?, &yaml_definition, @@ -367,6 +381,7 @@ fn parse_definition(yaml: &str) -> Value { /// (from a relay event) and the write path (from local inputs). fn workflow_record( id: String, + revision: String, channel_id: Option, owner_pubkey: String, yaml_definition: &str, @@ -383,6 +398,7 @@ fn workflow_record( WorkflowWire { id, + revision, name, owner_pubkey, channel_id, @@ -398,7 +414,15 @@ fn workflow_from_event(ev: &nostr::Event) -> WorkflowWire { let id = tag_value(ev, "d").unwrap_or_default(); let channel_id = tag_value(ev, "h"); let ts = ev.created_at.as_secs() as i64; - workflow_record(id, channel_id, ev.pubkey.to_hex(), &ev.content, ts, ts) + workflow_record( + id, + ev.id.to_hex(), + channel_id, + ev.pubkey.to_hex(), + &ev.content, + ts, + ts, + ) } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/workflows_tests.rs b/desktop/src-tauri/src/commands/workflows_tests.rs index 647cc687064..4adbd521771 100644 --- a/desktop/src-tauri/src/commands/workflows_tests.rs +++ b/desktop/src-tauri/src/commands/workflows_tests.rs @@ -41,6 +41,7 @@ fn workflow_from_event_maps_all_fields() { let wf = workflow_from_event(&ev); assert_eq!(wf.id, WF); + assert_eq!(wf.revision, ev.id.to_hex()); assert_eq!(wf.channel_id.as_deref(), Some(CHAN)); assert_eq!(wf.owner_pubkey, ev.pubkey.to_hex()); assert_eq!(wf.name, "Greet on join"); @@ -120,6 +121,7 @@ fn tag_value_reads_d_and_h_and_misses_absent() { fn workflow_record_shapes_save_inputs() { let wf = workflow_record( WF.to_string(), + "revision-1".to_string(), Some(CHAN.to_string()), "deadbeef".to_string(), YAML, @@ -139,6 +141,7 @@ fn workflow_record_shapes_save_inputs() { fn save_wire_serializes_flat_with_optional_secret() { let workflow = workflow_record( WF.to_string(), + "revision-1".to_string(), Some(CHAN.to_string()), "deadbeef".to_string(), YAML, @@ -176,6 +179,7 @@ fn workflow_wire_serializes_with_snake_case_keys() { let v = serde_json::to_value(workflow_from_event(&ev)).expect("serialize"); for key in [ "id", + "revision", "name", "owner_pubkey", "channel_id", diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index df814afb36f..1828b3f5605 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -756,47 +756,12 @@ pub fn build_dm_hide(channel_id: &str) -> Result { Ok(EventBuilder::new(Kind::Custom(41012), "").tags(tags)) } -/// Kind 30620 — replaceable workflow definition. -/// -/// The `d` tag carries the workflow id; `h` tag carries the channel id; the -/// content is the YAML definition. Same (pubkey, d) replaces the prior version. -pub fn build_workflow_definition( - workflow_id: &str, - channel_id: &str, - yaml_definition: &str, -) -> Result { - check_content(yaml_definition)?; - let tags = vec![tag(vec!["d", workflow_id])?, tag(vec!["h", channel_id])?]; - Ok(EventBuilder::new(Kind::Custom(30620), yaml_definition.to_string()).tags(tags)) -} - -/// Kind 5 — NIP-09 deletion targeting a kind:30620 workflow definition. -pub fn build_workflow_delete( - workflow_id: &str, - owner_pubkey_hex: &str, -) -> Result { - let coord = format!("30620:{owner_pubkey_hex}:{workflow_id}"); - let tags = vec![tag(vec!["a", &coord])?]; - Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags)) -} +mod workflows; -/// Kind 46020 — trigger a workflow run by id. -pub fn build_workflow_trigger(workflow_id: &str) -> Result { - let tags = vec![tag(vec!["d", workflow_id])?]; - Ok(EventBuilder::new(Kind::Custom(46020), "").tags(tags)) -} - -/// Kind 46030 — grant an approval token (with optional note). -pub fn build_approval_grant(token: &str, note: Option<&str>) -> Result { - let tags = vec![tag(vec!["t", token])?]; - Ok(EventBuilder::new(Kind::Custom(46030), note.unwrap_or("")).tags(tags)) -} - -/// Kind 46031 — deny an approval token (with optional note). -pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result { - let tags = vec![tag(vec!["t", token])?]; - Ok(EventBuilder::new(Kind::Custom(46031), note.unwrap_or("")).tags(tags)) -} +pub use workflows::{ + build_approval_deny, build_approval_grant, build_workflow_definition, build_workflow_delete, + build_workflow_trigger, +}; // ── Transport ──────────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/events/workflows.rs b/desktop/src-tauri/src/events/workflows.rs new file mode 100644 index 00000000000..8615f73851f --- /dev/null +++ b/desktop/src-tauri/src/events/workflows.rs @@ -0,0 +1,50 @@ +use nostr::{EventBuilder, EventId, Kind}; + +use super::{check_content, tag}; + +/// Kind 30620 — replaceable workflow definition. +/// +/// The `d` tag carries the workflow id; `h` tag carries the channel id; the +/// content is the YAML definition. Same (pubkey, d) replaces the prior version. +pub fn build_workflow_definition( + workflow_id: &str, + channel_id: &str, + yaml_definition: &str, + expected_revision: Option<&str>, +) -> Result { + check_content(yaml_definition)?; + let mut tags = vec![tag(vec!["d", workflow_id])?, tag(vec!["h", channel_id])?]; + if let Some(revision) = expected_revision { + EventId::from_hex(revision).map_err(|_| "invalid workflow revision".to_string())?; + tags.push(tag(vec!["expected-revision", revision])?); + } + Ok(EventBuilder::new(Kind::Custom(30620), yaml_definition.to_string()).tags(tags)) +} + +/// Kind 5 — NIP-09 deletion targeting a kind:30620 workflow definition. +pub fn build_workflow_delete( + workflow_id: &str, + owner_pubkey_hex: &str, +) -> Result { + let coord = format!("30620:{owner_pubkey_hex}:{workflow_id}"); + let tags = vec![tag(vec!["a", &coord])?]; + Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags)) +} + +/// Kind 46020 — trigger a workflow run by id. +pub fn build_workflow_trigger(workflow_id: &str) -> Result { + let tags = vec![tag(vec!["d", workflow_id])?]; + Ok(EventBuilder::new(Kind::Custom(46020), "").tags(tags)) +} + +/// Kind 46030 — grant an approval token (with optional note). +pub fn build_approval_grant(token: &str, note: Option<&str>) -> Result { + let tags = vec![tag(vec!["t", token])?]; + Ok(EventBuilder::new(Kind::Custom(46030), note.unwrap_or("")).tags(tags)) +} + +/// Kind 46031 — deny an approval token (with optional note). +pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result { + let tags = vec![tag(vec!["t", token])?]; + Ok(EventBuilder::new(Kind::Custom(46031), note.unwrap_or("")).tags(tags)) +} diff --git a/desktop/src/features/workflows/hooks.ts b/desktop/src/features/workflows/hooks.ts index a8bb1d6f581..86c12a04706 100644 --- a/desktop/src/features/workflows/hooks.ts +++ b/desktop/src/features/workflows/hooks.ts @@ -185,12 +185,15 @@ export function useCreateWorkflowMutation(channelId: string) { }); } -export function useUpdateWorkflowMutation(workflowId: string) { +export function useUpdateWorkflowMutation( + workflowId: string, + workflowRevision: string, +) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (yamlDefinition: string) => - updateWorkflow(workflowId, yamlDefinition), + updateWorkflow(workflowId, yamlDefinition, workflowRevision), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: workflowQueryKey(workflowId), diff --git a/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx b/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx new file mode 100644 index 00000000000..69cd3103f30 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx @@ -0,0 +1,105 @@ +import { + Copy, + MoreHorizontal, + Pencil, + Play, + Power, + PowerOff, + Trash2, +} from "lucide-react"; + +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +type WorkflowActionsMenuProps = { + isEnabled: boolean; + isTogglingEnabled?: boolean; + onDelete: () => void; + onDuplicate: () => void; + onEdit: () => void; + onToggleEnabled: () => void; + onTrigger: () => void; +}; + +export function WorkflowActionsMenu({ + isEnabled, + isTogglingEnabled = false, + onDelete, + onDuplicate, + onEdit, + onToggleEnabled, + onTrigger, +}: WorkflowActionsMenuProps) { + return ( + + + + + + + + Trigger + + + + Edit + + + + Duplicate + + { + if (checked !== isEnabled) onToggleEnabled(); + }} + onSelect={(event) => event.preventDefault()} + > + {isEnabled ? ( + + ) : ( + + )} + Enable + + + + + Delete + + + + ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowCard.tsx b/desktop/src/features/workflows/ui/WorkflowCard.tsx index cd15a0f52f6..2ca345fb011 100644 --- a/desktop/src/features/workflows/ui/WorkflowCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowCard.tsx @@ -1,138 +1,181 @@ import { - Clock, - Copy, - MoreHorizontal, - Pencil, - Play, - Trash2, + ArrowRight, + CalendarClock, + CircleCheckBig, + GitPullRequest, + Hash, + MessageCircle, + MessageSquare, + Send, + SmilePlus, + Timer, + Webhook, Zap, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import type { Workflow } from "@/shared/api/types"; -import { Badge } from "@/shared/ui/badge"; -import { Button } from "@/shared/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; +import { cn } from "@/shared/lib/cn"; +import { WorkflowActionsMenu } from "./WorkflowActionsMenu"; import { getWorkflowDescription, getWorkflowDisplayStatus, + getWorkflowEnabled, + getWorkflowPrimaryAction, getWorkflowTriggerSummary, + getWorkflowTriggerType, } from "./workflowDefinition"; type WorkflowCardProps = { workflow: Workflow; channelName?: string; isActive?: boolean; + isTogglingEnabled?: boolean; onSelect: (workflowId: string) => void; onTrigger: (workflowId: string) => void; + onToggleEnabled: (workflow: Workflow) => void; onEdit: (workflow: Workflow) => void; onDuplicate: (workflow: Workflow) => void; onDelete: (workflow: Workflow) => void; }; -function StatusBadge({ status }: { status: Workflow["status"] }) { - const variants: Record< - Workflow["status"], - "success" | "secondary" | "warning" - > = { - active: "success", - disabled: "secondary", - archived: "warning", - }; +const TRIGGER_ICONS: Record = { + diff_posted: GitPullRequest, + message_posted: MessageSquare, + reaction_added: SmilePlus, + schedule: CalendarClock, + webhook: Webhook, +}; - return {status}; +const ACTION_ICONS: Record = { + add_reaction: SmilePlus, + call_webhook: Webhook, + delay: Timer, + request_approval: CircleCheckBig, + send_dm: MessageCircle, + send_message: Send, + set_channel_topic: Hash, +}; + +const TRIGGER_ACCENTS: Record = { + diff_posted: "border-violet-400/30 bg-violet-600 text-white", + message_posted: "border-blue-400/30 bg-blue-600 text-white", + reaction_added: "border-pink-400/30 bg-pink-600 text-white", + schedule: "border-emerald-400/30 bg-emerald-600 text-white", + webhook: "border-orange-300/30 bg-orange-500 text-white", +}; + +function StatusBadge({ status }: { status: Workflow["status"] }) { + return ( + + {status} + + ); } export function WorkflowCard({ workflow, channelName, isActive = false, + isTogglingEnabled = false, onSelect, onTrigger, + onToggleEnabled, onEdit, onDuplicate, onDelete, }: WorkflowCardProps) { const displayStatus = getWorkflowDisplayStatus(workflow); - const description = getWorkflowDescription(workflow.definition); const triggerSummary = getWorkflowTriggerSummary(workflow.definition); + const description = getWorkflowDescription(workflow.definition); + const triggerType = getWorkflowTriggerType(workflow.definition); + const actionType = getWorkflowPrimaryAction(workflow.definition); + const TriggerIcon = triggerType ? TRIGGER_ICONS[triggerType] : undefined; + const ActionIcon = actionType ? ACTION_ICONS[actionType] : undefined; + const triggerAccent = triggerType ? TRIGGER_ACCENTS[triggerType] : undefined; return (
-
-
-
- - - {workflow.name} +
+
+ -
- {channelName ? {channelName} : null} - {triggerSummary ? {triggerSummary} : null} - - - {new Date(workflow.updatedAt * 1000).toLocaleDateString()} - + +
+ + onDelete(workflow)} + onDuplicate={() => onDuplicate(workflow)} + onEdit={() => onEdit(workflow)} + onToggleEnabled={() => onToggleEnabled(workflow)} + onTrigger={() => onTrigger(workflow.id)} + />
- {description ? ( -

- {description} -

- ) : null}
- - - - - - onTrigger(workflow.id)}> - - Trigger - - onEdit(workflow)}> - - Edit - - onDuplicate(workflow)}> - - Duplicate - - onDelete(workflow)} - > - - Delete - - - + {triggerSummary ? ( +

+ {triggerSummary} +

+ ) : null} +

+ {workflow.name} +

+ {description ? ( +

+ {description} +

+ ) : null} + +
+

+ {channelName ? `#${channelName}` : "Channel workflow"} +

+ + {new Date(workflow.updatedAt * 1000).toLocaleDateString()} + +
); diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx index 588ec896c6b..5ce3a0d2ddb 100644 --- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx @@ -83,7 +83,10 @@ export function WorkflowDialog({ } | null>(null); const createMutation = useCreateWorkflowMutation(selectedChannelId); - const updateMutation = useUpdateWorkflowMutation(workflow?.id ?? ""); + const updateMutation = useUpdateWorkflowMutation( + workflow?.id ?? "", + workflow?.revision ?? "", + ); const mutation = mode === "edit" ? updateMutation : createMutation; const selectedChannel = diff --git a/desktop/src/features/workflows/ui/WorkflowsView.tsx b/desktop/src/features/workflows/ui/WorkflowsView.tsx index 56aa85fd741..e0e5b7e9484 100644 --- a/desktop/src/features/workflows/ui/WorkflowsView.tsx +++ b/desktop/src/features/workflows/ui/WorkflowsView.tsx @@ -1,10 +1,13 @@ -import { Plus, RefreshCw, Zap } from "lucide-react"; +import { Plus, RefreshCw } from "lucide-react"; import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { stringify as yamlStringify } from "yaml"; import { allWorkflowsQueryKey, workflowListFocusRefetchPolicy, + workflowQueryKey, } from "@/features/workflows/hooks"; import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard"; import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog"; @@ -15,10 +18,12 @@ import { deleteWorkflow, getChannelsWorkflows, triggerWorkflow, + updateWorkflow, } from "@/shared/api/tauriWorkflows"; import { Button } from "@/shared/ui/button"; -import { Card } from "@/shared/ui/card"; +import { PageHeader } from "@/shared/ui/PageHeader"; import { Skeleton } from "@/shared/ui/skeleton"; +import { getWorkflowEnabled, withWorkflowEnabled } from "./workflowDefinition"; type WorkflowsViewProps = { channels: Channel[]; @@ -38,35 +43,45 @@ type DialogState = | { mode: "edit"; workflow: Workflow } | { mode: "duplicate"; workflow: Workflow }; +const WORKFLOW_CARD_GRID_CLASS = + "grid grid-cols-1 gap-3 [@container(min-width:38rem)]:grid-cols-2 [@container(min-width:54rem)]:grid-cols-3"; + function WorkflowsListSkeleton() { return ( -
+
{["first", "second", "third", "fourth"].map((card) => ( - -
-
-
- - -
- -
- - - -
-
-
- - -
+
+
+ +
- + + + + +
))}
); } +function CreateWorkflowCard({ onClick }: { onClick: () => void }) { + return ( + + ); +} + export function WorkflowsView({ channels, onCloseWorkflow, @@ -132,6 +147,38 @@ export function WorkflowsView({ }, }); + const toggleEnabledMutation = useMutation({ + mutationFn: (workflow: Workflow) => + updateWorkflow( + workflow.id, + yamlStringify( + withWorkflowEnabled( + workflow.definition, + !getWorkflowEnabled(workflow.definition), + ), + ), + workflow.revision, + ), + onError: (error) => { + toast.error("Couldn’t change workflow status", { + description: + error instanceof Error + ? error.message + : "The workflow was not changed. Try again.", + }); + }, + onSuccess: (_data, workflow) => { + void queryClient.invalidateQueries({ + queryKey: workflowQueryKey(workflow.id), + }); + void queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === "workflows" || + query.queryKey[0] === "workflows-all", + }); + }, + }); + const triggerOne = triggerMutation.mutate; const handleTrigger = React.useCallback( (workflowId: string) => triggerOne(workflowId), @@ -162,6 +209,12 @@ export function WorkflowsView({ [], ); + const toggleEnabled = toggleEnabledMutation.mutate; + const handleToggleEnabled = React.useCallback( + (workflow: Workflow) => toggleEnabled(workflow), + [toggleEnabled], + ); + const handleDialogOpenChange = React.useCallback((open: boolean) => { if (!open) { setDialogState({ mode: "closed" }); @@ -174,73 +227,67 @@ export function WorkflowsView({ data-testid="workflows-view" >
-
-
-

Workflows

- -
- -
+
+ void allWorkflowsQuery.refetch()} + size="icon" + variant="ghost" + > + + + } + description="Automations that keep your community moving." + title="Workflows" + /> - {allWorkflowsQuery.isLoading ? ( - - ) : allWorkflowsQuery.isError ? ( -
-

Failed to load workflows

- -
- ) : allWorkflows.length === 0 ? ( -
- -

No workflows yet

- -
- ) : ( -
- {allWorkflows.map(({ workflow, channelName }) => ( - + ) : allWorkflowsQuery.isError ? ( +
+

Failed to load workflows

+ +
+ ) : ( +
+ setDialogState({ mode: "create" })} /> - ))} -
- )} + {allWorkflows.map(({ workflow, channelName }) => ( + + ))} +
+ )} +
{selectedWorkflowId ? ( diff --git a/desktop/src/features/workflows/ui/workflowDefinition.test.mjs b/desktop/src/features/workflows/ui/workflowDefinition.test.mjs new file mode 100644 index 00000000000..57ced7d9c14 --- /dev/null +++ b/desktop/src/features/workflows/ui/workflowDefinition.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getWorkflowDisplayStatus, + getWorkflowPrimaryAction, + getWorkflowTriggerType, + withWorkflowEnabled, +} from "./workflowDefinition.ts"; + +test("reads only direct trigger and first-action types for card icons", () => { + assert.equal( + getWorkflowTriggerType({ trigger: { on: "message_posted" } }), + "message_posted", + ); + assert.equal( + getWorkflowPrimaryAction({ + steps: [{ action: "send_message" }, { action: "delay" }], + }), + "send_message", + ); + assert.equal(getWorkflowTriggerType({ trigger: { on: "" } }), null); + assert.equal(getWorkflowPrimaryAction({ steps: [null] }), null); +}); + +test("updates enabled state without mutating the workflow definition", () => { + const definition = { + name: "deploy", + trigger: { on: "message_posted" }, + }; + const disabled = withWorkflowEnabled(definition, false); + + assert.deepEqual(disabled, { ...definition, enabled: false }); + assert.deepEqual(definition, { + name: "deploy", + trigger: { on: "message_posted" }, + }); + assert.deepEqual(withWorkflowEnabled(disabled, true), definition); +}); + +test("shows a disabled definition as disabled while preserving other statuses", () => { + const workflow = { + id: "workflow-id", + name: "deploy", + channelId: "channel-id", + definition: { enabled: false }, + status: "active", + createdAt: 1, + updatedAt: 1, + }; + + assert.equal(getWorkflowDisplayStatus(workflow), "disabled"); + assert.equal( + getWorkflowDisplayStatus({ ...workflow, status: "archived" }), + "archived", + ); +}); diff --git a/desktop/src/features/workflows/ui/workflowDefinition.ts b/desktop/src/features/workflows/ui/workflowDefinition.ts index 2f858ced43e..d70b40abd16 100644 --- a/desktop/src/features/workflows/ui/workflowDefinition.ts +++ b/desktop/src/features/workflows/ui/workflowDefinition.ts @@ -9,12 +9,46 @@ function asRecord(value: unknown): Record | null { return value as Record; } +export function getWorkflowTriggerType( + definition: Record, +): string | null { + const trigger = asRecord(definition.trigger); + return typeof trigger?.on === "string" && trigger.on.trim().length > 0 + ? trigger.on + : null; +} + +export function getWorkflowPrimaryAction( + definition: Record, +): string | null { + const firstStep = Array.isArray(definition.steps) + ? asRecord(definition.steps[0]) + : null; + return typeof firstStep?.action === "string" && + firstStep.action.trim().length > 0 + ? firstStep.action + : null; +} + export function getWorkflowEnabled( definition: Record, ): boolean { return definition.enabled !== false; } +export function withWorkflowEnabled( + definition: Record, + enabled: boolean, +): Record { + const updated = { ...definition }; + if (enabled) { + delete updated.enabled; + } else { + updated.enabled = false; + } + return updated; +} + export function getWorkflowDisplayStatus( workflow: Workflow, ): Workflow["status"] | "disabled" { diff --git a/desktop/src/shared/api/tauriWorkflows.ts b/desktop/src/shared/api/tauriWorkflows.ts index 0247e1f2111..ff9d886cd36 100644 --- a/desktop/src/shared/api/tauriWorkflows.ts +++ b/desktop/src/shared/api/tauriWorkflows.ts @@ -13,6 +13,7 @@ import type { type RawWorkflow = { id: string; + revision: string; name: string; owner_pubkey: string; channel_id: string | null; @@ -94,6 +95,7 @@ type RawApprovalActionResponse = { function fromRawWorkflow(raw: RawWorkflow): Workflow { return { id: raw.id, + revision: raw.revision, name: raw.name, ownerPubkey: raw.owner_pubkey, channelId: raw.channel_id, @@ -220,10 +222,12 @@ export async function createWorkflow( export async function updateWorkflow( workflowId: string, yamlDefinition: string, + expectedRevision: string, ): Promise { const raw = await invokeTauri("update_workflow", { workflowId, yamlDefinition, + expectedRevision, }); return fromRawWorkflowSave(raw); } diff --git a/desktop/src/shared/api/workflowTypes.ts b/desktop/src/shared/api/workflowTypes.ts index 3eb7f393f0b..66e7e155bb6 100644 --- a/desktop/src/shared/api/workflowTypes.ts +++ b/desktop/src/shared/api/workflowTypes.ts @@ -2,6 +2,7 @@ export type WorkflowStatus = "active" | "disabled" | "archived"; export type Workflow = { id: string; + revision: string; name: string; ownerPubkey: string; channelId: string | null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f0e530172e0..705e46e2c98 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -236,6 +236,8 @@ type E2eConfig = { acpAuthMethods?: Record; acpAuthMethodsErrors?: Record; acpAuthMethodsError?: string; + /** When set, workflow updates fail with this message. */ + workflowUpdateError?: string; /** When set, the `delete_custom_harness` mock command throws with this message. */ deleteCustomHarnessError?: string; connectAcpRuntimeResult?: RawConnectAcpRuntimeResult; @@ -3356,6 +3358,7 @@ let mockRelayAgents: RawRelayAgent[] = defaultMockRelayAgents.map((agent) => ({ type MockWorkflow = { id: string; + revision: string; name: string; owner_pubkey: string; channel_id: string | null; @@ -3443,6 +3446,7 @@ function handleCreateWorkflow(args: { : `workflow_${mockWorkflowIdCounter}`; const workflow: MockWorkflow = { id: `mock-wf-${mockWorkflowIdCounter}`, + revision: `mock-revision-${mockWorkflowIdCounter}-1`, name, owner_pubkey: MOCK_IDENTITY_PUBKEY, channel_id: args.channelId, @@ -3466,13 +3470,22 @@ function handleCreateWorkflow(args: { function handleUpdateWorkflow(args: { workflowId: string; yamlDefinition: string; + expectedRevision: string; }) { const workflow = mockWorkflows.find((w) => w.id === args.workflowId); if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`); + const configuredError = window.__BUZZ_E2E__?.mock?.workflowUpdateError; + if (configuredError) throw new Error(configuredError); + if (workflow.revision !== args.expectedRevision) { + throw new Error( + "workflow changed since it was loaded; refresh and try again", + ); + } const definition = parseWorkflowDefinition(args.yamlDefinition); if (typeof definition.name === "string") workflow.name = definition.name; workflow.definition = definition; workflow.updated_at = Math.floor(Date.now() / 1000); + workflow.revision = `mock-revision-${workflow.id}-${workflow.updated_at}-${Math.random()}`; const trigger = definition.trigger as Record | undefined; return { diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index b1f2c6de28b..0681b22f493 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -62,13 +62,13 @@ async function createWorkflow( ).not.toBeVisible(); } -test("navigates to workflows view and shows empty state", async ({ page }) => { +test("navigates to workflows view and shows the empty create tile", async ({ + page, +}) => { await navigateToWorkflows(page); - await expect(page.getByText("No workflows yet")).toBeVisible(); - await expect( - page.getByRole("button", { name: "Create your first workflow" }), - ).toBeVisible(); + await expect(page.getByTestId("new-workflow-card")).toBeVisible(); + await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0); }); test("creates a workflow via the form builder", async ({ page }) => { @@ -99,6 +99,49 @@ test("disables autocapitalization in the workflow form", async ({ page }) => { ); }); +test("captures workflow library across responsive viewports", async ({ + page, +}) => { + await navigateToWorkflows(page); + await createWorkflow(page, "Notify reviewers when source files change", { + description: "Watches diff events for src/ changes", + enabled: false, + trigger: "diff_posted", + }); + await createWorkflow(page, "Post the daily standup reminder to the team", { + description: "Keeps the team aligned every morning", + trigger: "schedule", + }); + await createWorkflow( + page, + "Request approval before deploying to production", + { + description: "Requires a final review before release", + trigger: "reaction_added", + }, + ); + + for (const viewport of [ + { width: 800, height: 720, name: "narrow" }, + { width: 1024, height: 720, name: "medium" }, + { width: 1280, height: 720, name: "wide" }, + ]) { + await page.setViewportSize(viewport); + await page.screenshot({ + animations: "disabled", + path: `test-results/workflow-library-${viewport.name}.png`, + }); + } + + await page.setViewportSize({ width: 1280, height: 720 }); + const firstCard = page.locator('[data-testid^="workflow-card-"]').first(); + await firstCard.getByRole("button", { name: "Workflow actions" }).click(); + await page.screenshot({ + animations: "disabled", + path: "test-results/workflow-library-wide-actions.png", + }); +}); + test("captures disabled diff workflows in the list UI", async ({ page }) => { const workflowName = `diff_workflow_${Date.now()}`; const description = "Watches diff events for src/ changes"; @@ -117,12 +160,145 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => { .locator('[data-testid^="workflow-card-"]') .filter({ hasText: workflowName }) .first(); - await expect(card).toContainText(workflowName); - await expect(card).toContainText(description); - await expect(card).toContainText("Diff Posted"); + await expect(card.getByText("Diff Posted", { exact: true })).toBeVisible(); + await expect(card.locator("h3")).toHaveText(workflowName); + await expect(card.getByText(description, { exact: true })).toBeVisible(); await expect(card).toContainText("disabled"); }); +test("enables and disables a workflow from its card menu", async ({ page }) => { + const workflowName = `toggle_workflow_${Date.now()}`; + + await navigateToWorkflows(page); + await createWorkflow(page, workflowName); + + const workflowCard = () => + page + .locator('[data-testid^="workflow-card-"]') + .filter({ hasText: workflowName }) + .first(); + const workflowActions = () => + workflowCard().getByRole("button", { name: "Workflow actions" }); + + const enableItem = page.getByRole("menuitemcheckbox", { name: "Enable" }); + + await page.getByRole("button", { name: `View ${workflowName}` }).click(); + const detailPanel = page.getByTestId("workflow-detail-panel"); + await expect(detailPanel).toBeVisible(); + await expect(detailPanel.getByText("active", { exact: true })).toBeVisible(); + + await workflowActions().click(); + await expect(enableItem).toHaveAttribute("aria-checked", "true"); + await expect(enableItem.locator("button")).toHaveCount(0); + await expect( + enableItem.getByTestId("workflow-enabled-switch-visual"), + ).toHaveAttribute("aria-hidden", "true"); + await enableItem.click(); + await expect( + workflowCard().getByText("disabled", { exact: true }), + ).toBeVisible(); + await expect( + detailPanel.getByText("disabled", { exact: true }), + ).toBeVisible(); + + await enableItem.click(); + await expect( + workflowCard().getByText("active", { exact: true }), + ).toBeVisible(); + await expect(detailPanel.getByText("active", { exact: true })).toBeVisible(); +}); + +test("rejects a stale card toggle without overwriting a newer edit", async ({ + page, +}) => { + const workflowName = `stale_toggle_${Date.now()}`; + + await navigateToWorkflows(page); + await createWorkflow(page, workflowName); + const workflowCard = page + .locator('[data-testid^="workflow-card-"]') + .filter({ hasText: workflowName }) + .first(); + + await page.evaluate(async (name) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("mock command bridge unavailable"); + const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] + .reverse() + .find((call) => call.command === "create_workflow"); + const channelId = ( + createCall?.payload as { channelId?: string } | undefined + )?.channelId; + if (!channelId) throw new Error("create workflow channel unavailable"); + const workflows = (await invoke("get_channels_workflows", { + channelIds: [channelId], + })) as Array<{ + id: string; + revision: string; + definition: Record; + }>; + const workflow = workflows.find( + (candidate) => candidate.definition.name === name, + ); + if (!workflow) throw new Error("created workflow unavailable"); + await invoke("update_workflow", { + workflowId: workflow.id, + expectedRevision: workflow.revision, + yamlDefinition: `name: ${name} edited elsewhere\nenabled: true\ntrigger:\n on: message_posted\nsteps:\n - id: step_1\n action: post_message\n`, + }); + }, workflowName); + + await workflowCard.getByRole("button", { name: "Workflow actions" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Enable" }).click(); + + await expect( + page + .locator("[data-sonner-toast][data-removed='false']") + .filter({ hasText: "workflow changed since it was loaded" }), + ).toBeVisible(); + const authoritativeName = await page.evaluate(async () => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("mock command bridge unavailable"); + const createCall = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] + .reverse() + .find((call) => call.command === "create_workflow"); + const channelId = ( + createCall?.payload as { channelId?: string } | undefined + )?.channelId; + if (!channelId) throw new Error("create workflow channel unavailable"); + const workflows = (await invoke("get_channels_workflows", { + channelIds: [channelId], + })) as Array<{ name: string }>; + return workflows[0]?.name; + }); + expect(authoritativeName).toBe(`${workflowName} edited elsewhere`); +}); + +test("reports a rejected workflow status change", async ({ page }) => { + const workflowName = `rejected_toggle_${Date.now()}`; + + await navigateToWorkflows(page); + await createWorkflow(page, workflowName); + await page.evaluate(() => { + window.__BUZZ_E2E__ ??= {}; + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.workflowUpdateError = "relay refused the update"; + }); + + const workflowCard = page + .locator('[data-testid^="workflow-card-"]') + .filter({ hasText: workflowName }) + .first(); + await workflowCard.getByRole("button", { name: "Workflow actions" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Enable" }).click(); + + const errorToast = page + .locator("[data-sonner-toast][data-removed='false']") + .filter({ hasText: "Couldn’t change workflow status" }); + await expect(errorToast).toContainText("relay refused the update"); + await expect(workflowCard.getByText("active", { exact: true })).toBeVisible(); +}); + test("shows the webhook secret dialog after saving a webhook workflow", async ({ page, }) => { @@ -215,8 +391,9 @@ test("deletes a workflow with confirmation", async ({ page }) => { await page.getByRole("button", { name: "Delete" }).click(); await expect(page.getByRole("alertdialog")).not.toBeVisible(); - // Verify workflow is gone — back to empty state - await expect(page.getByText("No workflows yet")).toBeVisible(); + // Verify workflow is gone — back to the empty create tile. + await expect(page.getByTestId("new-workflow-card")).toBeVisible(); + await expect(page.locator('[data-testid^="workflow-card-"]')).toHaveCount(0); }); test("triggers a workflow from the detail panel", async ({ page }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 9d288825e56..815c362fc10 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -192,6 +192,7 @@ type MockBridgeOptions = { acpAuthMethods?: Record[] }>; acpAuthMethodsError?: string; /** When set, the `delete_custom_harness` mock command throws with this message. */ + workflowUpdateError?: string; deleteCustomHarnessError?: string; connectAcpRuntimeResult?: { launched: boolean }; connectAcpRuntimeDelayMs?: number; From 5b3f0375a26843d73b29b55cc2f3c313bd857ccb Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Mon, 17 Aug 2026 13:03:14 -0400 Subject: [PATCH 04/16] fix(acp): replace Goose native system prompt (#5964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Buzz currently appends its managed prompt to Goose's native prompt, so managed agents receive both instruction sets instead of the intended Buzz-only system prompt. ## What - Send Goose's custom session system-prompt request with `mode: "set"` - Lock the replacement contract in the ACP request test ## Risk Assessment Low — the change is limited to Goose session setup; adapters that do not implement Goose's custom method keep the existing method-not-found fallback behavior. ## References Goose v1.46.0 routes `set` to `override_system_prompt`, and its prompt builder selects that override instead of rendering the native `system.md`: [ACP handler](https://github.com/aaif-goose/goose/blob/98c11ce2ee7b9b302978aa64b1eab7d0895607c7/crates/goose/src/acp/server/manage_sessions.rs#L57-L93), [prompt builder](https://github.com/aaif-goose/goose/blob/98c11ce2ee7b9b302978aa64b1eab7d0895607c7/crates/goose/src/agents/prompt_manager.rs#L153-L191). Validated end to end against the official Goose v1.46.0 binary with a local OpenAI-compatible capture server: the provider request contained the exact Buzz replacement prompt and did not contain Goose's native base-prompt marker. --- **Update Aug 15, 13:17 CDT:** Added the [Terra-high prompt-ablation comparison](https://github.com/squareup/buzz-benchmarks/blob/4492f76349ccb638219f7d070735a4d2b679bc26/data/prompt-ablation/20260815-terra-high/comparison.md). The Goose conditions used GPT 5.6 Terra at high effort on the same 11 Terminal-Bench 2.1 tasks, with two attempts per task and concurrency four. The matched `append-full` and `set-full` runs used the same persona and included the same Buzz platform prompt; Active-h is the primary measure because it excludes Buzz lifecycle overhead. | Goose condition | Pass | Active-h | Median active | Agent-h | Wall-h | Tool calls | |---|---:|---:|---:|---:|---:|---:| | Native prompt + Buzz prompt (`append-full`) | 21/22 | 0.3042 | 0.85 min | 0.3974 | 0.1496 | 234 | | Native prompt + persona only (`append-persona-only`) | 22/22 | 0.3050 | 0.75 min | 0.3990 | 0.1498 | 204 | | Buzz prompt replaces native prompt (`set-full`) | 22/22 | 0.3340 | 0.87 min | 0.4296 | 0.1551 | 275 | Replacing instead of appending produced one additional passing attempt, but it was not an efficiency improvement in this small sample: versus `append-full`, `set-full` increased Active-h by 9.8%, median active by 2.0%, Agent-h by 8.1%, Wall-h by 3.7%, and tool calls by 17.5%. It was faster on only two of eleven per-task active-time medians (`distribution-search` and `prove-plus-comm`). With two attempts per task, these are directional results rather than confidence intervals; they support this change as an instruction-isolation/correctness fix, not a performance optimization, and argue against Goose's appended native prompt being the main source of active-time cost. Generated with Codex Signed-off-by: Atish Patel Co-authored-by: Codex --- crates/buzz-acp/src/acp.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index f04b8eeec0d..f8373bd66d8 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -702,7 +702,7 @@ impl AcpClient { .session_id) } - /// Send Goose's custom system-prompt request after `session/new`. + /// Replace Goose's native system prompt after `session/new`. pub async fn session_set_goose_system_prompt( &mut self, session_id: &str, @@ -712,7 +712,7 @@ impl AcpClient { "_goose/unstable/session/system-prompt/set", serde_json::json!({ "sessionId": session_id, - "mode": "append", + "mode": "set", "key": "buzz", "text": text, }), @@ -3421,7 +3421,7 @@ mod tests { } #[tokio::test] - async fn goose_system_prompt_request_uses_append_contract() { + async fn goose_system_prompt_request_uses_set_contract() { let script = r#" read -t 2 REQ echo '{"jsonrpc":"2.0","id":0,"result":{"_receivedRequest":'"$REQ"'}}' @@ -3438,7 +3438,7 @@ mod tests { "_goose/unstable/session/system-prompt/set" ); assert_eq!(received["params"]["sessionId"], "ses_goose"); - assert_eq!(received["params"]["mode"], "append"); + assert_eq!(received["params"]["mode"], "set"); assert_eq!(received["params"]["key"], "buzz"); assert_eq!(received["params"]["text"], "Be terse"); } From 54f11219efe6b2617ba74d1ef8701fb5413956d8 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Mon, 17 Aug 2026 13:29:20 -0400 Subject: [PATCH 05/16] fix(acp): gate relay-signed workflow messages on their attributed author (#6129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Scheduled workflow `send_message` actions fire and land in the channel with correct `p` tags for the mentioned agents — but the agents never wake. The wake-up is silently dropped. **Root cause:** workflow messages are signed by the **relay keypair** (`workflow_sink.rs` signs with `state.relay_keypair`), so `event.pubkey` is the relay's pubkey, not the workflow owner. In `buzz-acp`, the inbound author gate (`author_allowed`) runs **before** the `p`-tag mention check. Under the default `respond_to = owner-only`, the relay pubkey is neither the owner nor a sibling, so every workflow wake-up dies at the gate with a debug-level `"inbound author gate — dropping event"`. The relay-side comment even says the mention `p` tags exist *"so mentioned agents are woken (wake is p-tag gated)"* — but wake is also author-gated, and that path was missed. ## Fix Gate relay-signed workflow messages on their **attributed author** — the pubkey that created the workflow — instead of the relay pubkey: - **Relay:** `workflow_sink.rs` now emits an explicit `buzz:workflow-owner` tag carrying `workflow.owner_pubkey` (the workflow creator, which the executor already passes as `author_pubkey` and whose channel access the relay verifies before emitting). Ownership is never inferred from `p`-tag order; mention `p` tags play no role in attribution. - **Harness:** at startup, `buzz-acp` fetches the relay's NIP-11 `self` pubkey (new `RestClient::fetch_relay_self`, public `/info` endpoint). Best-effort: fetch failure just logs a warning and preserves pre-fix behavior. - **Gate:** an event that is (a) authored by the relay `self` key, (b) tagged `buzz:workflow`, and (c) carries a well-formed `buzz:workflow-owner` pubkey is gated on that owner, through the exact same owner/sibling/allowlist policy as a direct author. ## Security notes (all fail closed) - No NIP-11 `self` pubkey → no exemption. - `buzz:workflow` / `buzz:workflow-owner` tags on a non-relay-signed event → ignored (a member cannot forge the exemption; the relay verifies signatures on submission and only the relay holds its key). - Relay-signed event without the tags, or with a malformed owner value (not 64-hex) → plain author gate. - Who is @mentioned in the message has no bearing on whose authority is evaluated. - A workflow owned by a random channel member still cannot wake an owner-only agent — the owner's pubkey must pass the same policy. ## Testing - 7 unit tests (`workflow_attributed_author_tests`) covering attribution, fail-closed paths, p-tag independence, malformed owner values, and the forgery case. - Extended the PG-gated `workflow_send_message_p_tags_mentioned_member` integration test to assert the `buzz:workflow-owner` tag. - `cargo test -p buzz-acp`: 785 passed, 0 failed. `cargo test -p buzz-relay --lib workflow_sink`: 17 passed. Clippy + fmt clean. (9 pre-existing `buzz-relay` failures in unrelated `api::media`/`api::admin` tests fail identically on the base commit without this change.) Found while debugging scheduled automations in a Buzz review-pipeline channel: two cron workflows fired daily @mentions at agents that never responded, while direct human @mentions woke them instantly. --------- Signed-off-by: Luke Tornquist Co-authored-by: Fizz <3a9f8a30fbb462abec1e2977b2280a7ae50c7ff794433790be15bd48bfd52d0b@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/lib.rs | 390 ++++++++++++++++++++++++- crates/buzz-acp/src/relay.rs | 31 ++ crates/buzz-relay/src/workflow_sink.rs | 18 ++ 3 files changed, 438 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 7fd40b83db1..2a41ea73420 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2025,6 +2025,19 @@ async fn tokio_main() -> Result<()> { } let owner_cache = OwnerCache::new(startup_owner.clone()); + // Relay `self` pubkey (NIP-11), used to recognize relay-signed workflow + // messages in the inbound author gate. Best-effort: `None` simply means + // workflow messages get no attributed-author exemption (pre-fix behavior), + // so a fetch failure degrades gracefully instead of blocking startup. + let relay_self: Option = relay.rest_client().fetch_relay_self().await; + match &relay_self { + Some(pk) => tracing::info!("relay self pubkey: {pk}"), + None => tracing::warn!( + "relay self pubkey unavailable (NIP-11 fetch failed or no stable relay key) — \ + relay-signed workflow messages will be dropped by the author gate" + ), + } + let mut relay_observer_control_rx = None; let mut relay_observer_publisher_task = None; let mut relay_observer_publisher = None; @@ -2836,7 +2849,31 @@ async fn tokio_main() -> Result<()> { // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. { - let author = buzz_event.event.pubkey.to_hex(); + // Relay-signed workflow messages (workflow + // `send_message` actions) are authored by the + // relay keypair, not the workflow owner — the + // plain author gate would drop them and the + // scheduled @mention would silently never wake + // the agent. Gate them on their *attributed* + // author (the `buzz:workflow-owner` tag — the + // pubkey that created the workflow) instead. + // See `workflow_attributed_author` + // for the recognition + trust argument. + let author = match workflow_attributed_author( + &buzz_event.event, + relay_self.as_deref(), + ) { + Some(attributed) => { + tracing::debug!( + channel_id = %buzz_event.channel_id, + relay_author = %buzz_event.event.pubkey.to_hex(), + attributed_author = %attributed, + "relay-signed workflow message — gating on attributed author" + ); + attributed + } + None => buzz_event.event.pubkey.to_hex(), + }; // DM hardening: resolve channel type (fail-closed // to DM) so allowlist/anyone modes cannot be // exercised by non-owner authors inside DMs. @@ -3517,6 +3554,90 @@ fn event_mentions_agent(event: &nostr::Event, agent_pubkey_hex: &str) -> bool { }) } +/// If `event` is a relay-signed workflow message, return its *attributed* +/// author for inbound author gating; otherwise `None`. +/// +/// Workflow `send_message` actions are signed by the **relay keypair** +/// (`event.pubkey` = the relay's NIP-11 `self` key), not by the human who owns +/// the workflow — so the plain author gate would drop them even though they +/// carry `p` tags meant to wake mentioned agents. The relay attributes the +/// message to the **workflow owner** (the pubkey that created the workflow, +/// `workflow.owner_pubkey` relay-side) via the explicit `buzz:workflow-owner` +/// tag emitted by `workflow_sink.rs`, and it has already verified that owner's +/// access to the destination channel before emitting the event. +/// +/// Recognition requires ALL of the following, failing closed otherwise: +/// 1. kind `9` (stream message) — the only kind the workflow sink emits; +/// 2. a known, syntactically valid relay `self` pubkey (fetched from NIP-11 +/// at startup) — no `relay_self`, no exemption; +/// 3. `event.pubkey` == relay `self`, with a **valid event signature** +/// verified here. The relay verifies signatures on submission, but this +/// gate re-checks locally so the exemption never rests on an upstream +/// guarantee it can't see; +/// 4. **exactly one** tag exactly equal to `["buzz:workflow", "true"]` — no +/// duplicates, no extra fields, no other value; +/// 5. **exactly one** tag exactly equal to `["buzz:workflow-owner", ]` +/// where the owner parses as a full pubkey — no duplicates, no extra +/// fields. Mention `p` tags are never used for attribution, so who is +/// @mentioned in the message text has no bearing on whose authority the +/// gate evaluates. +/// +/// The returned pubkey is gated exactly like a direct author: owner/sibling +/// under `owner-only`, plus the explicit list under `allowlist`. A workflow +/// owned by a random channel member therefore still cannot wake an +/// owner-only agent. +fn workflow_attributed_author(event: &nostr::Event, relay_self: Option<&str>) -> Option { + // 1. Kind gate first — cheapest check, and everything below only makes + // sense for the kind:9 messages the workflow sink emits. + if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE { + return None; + } + + // 2. Relay identity must be known AND syntactically valid. + let relay_self = nostr::PublicKey::from_hex(relay_self?).ok()?; + if event.pubkey != relay_self { + return None; + } + + // 4. Exactly one marker tag, exactly ["buzz:workflow", "true"]. Collect + // every tag with the marker key so duplicates or shape/value mismatches + // (extra fields, wrong value) disqualify instead of being skipped over. + let markers: Vec<&[String]> = event + .tags + .iter() + .map(|t| t.as_slice()) + .filter(|s| s.first().map(|k| k.as_str()) == Some("buzz:workflow")) + .collect(); + if markers.len() != 1 || markers[0] != ["buzz:workflow", "true"] { + return None; + } + + // 5. Exactly one owner tag, exactly ["buzz:workflow-owner", ]. + // The owner must parse as a full pubkey — not merely look hex-ish — + // before it is fed into the owner/sibling/allowlist comparison. + let owners: Vec<&[String]> = event + .tags + .iter() + .map(|t| t.as_slice()) + .filter(|s| s.first().map(|k| k.as_str()) == Some("buzz:workflow-owner")) + .collect(); + let [owner_tag] = owners.as_slice() else { + return None; + }; + let [_, owner_value] = owner_tag else { + return None; + }; + let owner = nostr::PublicKey::from_hex(owner_value).ok()?; + + // 3. Signature check last — it is the most expensive step, so only pay + // for it once every structural requirement has already passed. + if event.verify().is_err() { + return None; + } + + Some(owner.to_hex()) +} + fn is_owner_control_command( event: &nostr::Event, kind_u32: u32, @@ -5673,6 +5794,273 @@ mod author_gate_tests { } } +#[cfg(test)] +mod workflow_attributed_author_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// Build a kind:9 event signed by `signer` with the given extra tags. + fn make_event(signer: &Keys, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "wake up") + .tags(tags) + .sign_with_keys(signer) + .expect("sign test event") + } + + fn workflow_tags(owner_hex: &str, mention_hex: &str) -> Vec { + vec![ + Tag::parse(["p", owner_hex]).unwrap(), + Tag::parse(["h", "3204e3f9-fd09-4e95-b749-76966794c287"]).unwrap(), + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["buzz:workflow-owner", owner_hex]).unwrap(), + Tag::parse(["p", mention_hex]).unwrap(), + ] + } + + #[test] + fn relay_signed_workflow_message_attributes_to_workflow_owner_tag() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let event = make_event(&relay, workflow_tags(&owner, &agent)); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + Some(owner), + "a relay-signed buzz:workflow event must attribute to the \ + buzz:workflow-owner tag, not any mentioned agent" + ); + } + + #[test] + fn attribution_ignores_p_tags_entirely() { + // Only the explicit buzz:workflow-owner tag attributes; p tags + // (owner attribution + mentions) must have no effect on the gate. + let relay = Keys::generate(); + let someone = Keys::generate().public_key().to_hex(); + let event = make_event( + &relay, + vec![ + Tag::parse(["p", &someone]).unwrap(), + Tag::parse(["buzz:workflow", "true"]).unwrap(), + ], + ); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "without a buzz:workflow-owner tag there is no attributed author, \ + even when p tags are present" + ); + } + + #[test] + fn malformed_owner_tag_value_attributes_to_no_one() { + let relay = Keys::generate(); + let event = make_event( + &relay, + vec![ + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["buzz:workflow-owner", "not-a-pubkey"]).unwrap(), + ], + ); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "a buzz:workflow-owner value that is not 64-hex must be rejected" + ); + } + + #[test] + fn no_relay_self_means_no_exemption() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let event = make_event(&relay, workflow_tags(&owner, &agent)); + assert_eq!( + workflow_attributed_author(&event, None), + None, + "without a known relay self pubkey the exemption must not apply (fail closed)" + ); + } + + #[test] + fn non_relay_author_gets_no_exemption_even_with_workflow_tag() { + // A member forging the buzz:workflow tag on their own event must not + // be able to attribute it to someone else via a p tag. + let forger = Keys::generate(); + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let event = make_event(&forger, workflow_tags(&owner, &agent)); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "a buzz:workflow tag on a non-relay-signed event must be ignored" + ); + } + + #[test] + fn relay_signed_message_without_workflow_tag_gets_no_exemption() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = make_event(&relay, vec![Tag::parse(["p", &owner]).unwrap()]); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "relay-signed events without the buzz:workflow tag keep the plain author gate" + ); + } + + #[test] + fn workflow_message_without_owner_tag_attributes_to_no_one() { + let relay = Keys::generate(); + let event = make_event(&relay, vec![Tag::parse(["buzz:workflow", "true"]).unwrap()]); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "a workflow message with no buzz:workflow-owner tag has no attributed \ + author and must fall through to the plain (relay-pubkey) author gate" + ); + } + + #[test] + fn duplicate_marker_tags_disqualify() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = make_event( + &relay, + vec![ + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["buzz:workflow-owner", &owner]).unwrap(), + ], + ); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "more than one buzz:workflow marker tag must fail closed" + ); + } + + #[test] + fn marker_value_mismatch_disqualifies() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + for bad_marker in [ + Tag::parse(["buzz:workflow", "false"]).unwrap(), + Tag::parse(["buzz:workflow"]).unwrap(), + Tag::parse(["buzz:workflow", "true", "extra"]).unwrap(), + ] { + let event = make_event( + &relay, + vec![ + bad_marker.clone(), + Tag::parse(["buzz:workflow-owner", &owner]).unwrap(), + ], + ); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "marker tag {:?} is not exactly [\"buzz:workflow\", \"true\"] and must fail closed", + bad_marker.as_slice() + ); + } + } + + #[test] + fn duplicate_owner_tags_disqualify() { + // Two owner tags — even with identical values — are ambiguous + // provenance and must not attribute to anyone. + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let other = Keys::generate().public_key().to_hex(); + for second_owner in [&owner, &other] { + let event = make_event( + &relay, + vec![ + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["buzz:workflow-owner", &owner]).unwrap(), + Tag::parse(["buzz:workflow-owner", second_owner]).unwrap(), + ], + ); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "duplicate buzz:workflow-owner tags must fail closed" + ); + } + } + + #[test] + fn owner_tag_with_extra_fields_disqualifies() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = make_event( + &relay, + vec![ + Tag::parse(["buzz:workflow", "true"]).unwrap(), + Tag::parse(["buzz:workflow-owner", &owner, "extra"]).unwrap(), + ], + ); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "an owner tag with extra fields is not the exact shape the relay \ + emits and must fail closed" + ); + } + + #[test] + fn wrong_kind_disqualifies() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let event = EventBuilder::new(Kind::from(1u16), "wake up") + .tags(workflow_tags(&owner, &agent)) + .sign_with_keys(&relay) + .expect("sign test event"); + assert_eq!( + workflow_attributed_author(&event, Some(&relay.public_key().to_hex())), + None, + "only kind:9 stream messages may use the workflow exemption" + ); + } + + #[test] + fn tampered_event_fails_signature_check() { + // Alter the content after signing: pubkey still matches relay_self + // and the tags are pristine, but the signature no longer covers the + // event — the local verify must reject it. + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let event = make_event(&relay, workflow_tags(&owner, &agent)); + let mut json = serde_json::to_value(&event).expect("event to JSON"); + json["content"] = serde_json::Value::String("tampered".into()); + let tampered: nostr::Event = serde_json::from_value(json).expect("tampered event parses"); + assert_eq!( + workflow_attributed_author(&tampered, Some(&relay.public_key().to_hex())), + None, + "a tampered event must fail the local signature check" + ); + } + + #[test] + fn syntactically_invalid_relay_self_means_no_exemption() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let event = make_event(&relay, workflow_tags(&owner, &agent)); + let long_not_hex = "zz".repeat(32); + for bad_self in ["", "not-hex", long_not_hex.as_str()] { + assert_eq!( + workflow_attributed_author(&event, Some(bad_self)), + None, + "an invalid NIP-11 self value {bad_self:?} must disable the exemption" + ); + } + } +} + #[cfg(test)] mod observer_snapshot_race_tests { use super::*; diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 17a818867dd..fc3a16ddb95 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -410,6 +410,37 @@ impl RestClient { .await } + /// Fetch the relay's own signing pubkey from the public NIP-11 `/info` + /// document (the `self` field, hex, normalized to lowercase). + /// + /// Used by the inbound author gate to recognize relay-signed workflow + /// messages (`buzz:workflow`-tagged kind:9 events authored by the relay + /// keypair) and gate them on their *attributed* author instead. + /// + /// Returns `None` when the document is unreachable, unparseable, or has + /// no valid `self` field (e.g. the relay runs with an ephemeral key). + /// Callers must treat `None` as "no relay-signed exemption" — fail closed + /// to the plain author gate, never guess a pubkey. + pub async fn fetch_relay_self(&self) -> Option { + let url = format!("{}/info", self.base_url); + let resp = self + .http + .get(&url) + .header("Accept", "application/nostr+json") + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + let doc: Value = resp.json().await.ok()?; + let self_hex = doc.get("self")?.as_str()?; + if self_hex.len() != 64 || !self_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + Some(self_hex.to_ascii_lowercase()) + } + /// Query events via the HTTP bridge: `POST /query` with NIP-98 auth. /// /// Accepts a slice of `nostr::Filter` (serialized as JSON array). diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c25611..e056ff736ad 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -255,6 +255,9 @@ impl ActionSink for RelayActionSink { // - `p` tag attributes the message to the workflow owner // - `h` tag scopes to the channel (NIP-29, canonical UUID) // - `buzz:workflow` tag prevents recursive workflow triggering + // - `buzz:workflow-owner` tag names the workflow owner explicitly, + // so consumers (e.g. the ACP inbound author gate) can attribute + // the message without inferring ownership from `p`-tag order // - one `p` tag per `@Name` that resolves to a channel member, // so mentioned agents are woken (wake is `p`-tag gated) let mut tags = vec![ @@ -264,6 +267,8 @@ impl ActionSink for RelayActionSink { .map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?, Tag::parse(["buzz:workflow", "true"]) .map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?, + Tag::parse(["buzz:workflow-owner", &author_pubkey_hex]) + .map_err(|e| ActionSinkError::EventBuild(format!("workflow owner tag: {e}")))?, ]; // Resolve `@Name` mentions to channel-member pubkeys and append a @@ -707,5 +712,18 @@ mod integration_tests { p_tag_targets.contains(&agent_hex.as_str()), "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" ); + + let owner_tag = stored + .event + .tags + .iter() + .find(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow-owner")) + .and_then(|t| t.as_slice().get(1).map(|s| s.as_str())); + assert_eq!( + owner_tag, + Some(author_hex.as_str()), + "workflow owner must be named explicitly via buzz:workflow-owner \ + so consumers never infer ownership from p-tag order" + ); } } From d12d82577818a95babac4d30cf242c46124feb5e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 17 Aug 2026 13:46:25 -0400 Subject: [PATCH 06/16] fix(desktop): resolve agent profiles through one archive-aware selector (#5706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent profiles resolve through one shared selector (`pickProfileAgent`) at every entry point — the persona card, the profile panel, and library grouping. That selector ranked instances only by active/name, with no archive awareness, so a relay-archived instance early in file order could hijack the persona card and the profile panel. The persona card also recorded a durable pubkey target, which could strand the panel on an archived identity when the click landed during the archive-snapshot fail-open window. The profile panel's Runtime → Instances roster had the same blind spot: it rendered every persona instance raw, so archived instances appeared mixed in with live ones as if active. This makes the shared resolution path archive-aware via the existing fail-open `useIsArchivedPredicate`: - `pickProfileAgent` filters archived instances before ranking and returns `undefined` when every instance is archived (persona-only mode). - `buildUnifiedGroups` drops archived agents from the standalone `Custom agents` and `Unknown agents` buckets; matched persona groups keep their full list and rely on the selector's persona-only fallback. - `useCanonicalManagedAgentProfile` resolves through a pure `resolveCanonicalManagedAgent` helper that applies the target-provenance rules: a deliberately requested archived pubkey stays exact (so its archive controller can unarchive it, even when a live sibling exists), `preserveRequestedInstance` still pins a Runtime → Instances selection, and non-archived historical navigation keeps its canonicalization. - The persona card's main click records a persona target that re-resolves every render, so it self-corrects to a live sibling after hydration. Deliberate instance navigation and the runtime-error affordance keep their explicit-pubkey path. - The Runtime → Instances roster (`ProfileInstancesSection`) buckets instances off the same predicate via `bucketPersonaInstances`: live rows render as before, and archived rows move under a labeled `Archived` subsection. The instance count reflects both buckets, and archived rows keep their explicit-pubkey click so unarchive stays UI-reachable (the deliberate-navigation path above). The predicate is fail-open (treats every identity as live while the relay archive snapshot loads) and self-exempt, so a cold start never hides an identity and a user is never folded from their own client. While the snapshot is loading, every instance renders in the live list — nothing hidden, nothing labeled. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- .../agents/lib/pickProfileAgent.test.mjs | 70 ++++- .../features/agents/lib/pickProfileAgent.ts | 32 +- .../agents/ui/UnifiedAgentsSection.tsx | 25 +- .../UnifiedAgentsSectionCardTarget.test.mjs | 295 ++++++++++++++++++ .../agents/ui/unifiedAgentGroups.test.mjs | 77 +++++ .../features/agents/ui/unifiedAgentGroups.ts | 18 +- .../lib/bucketPersonaInstances.test.mjs | 60 ++++ .../lib/resolveCanonicalManagedAgent.test.mjs | 157 ++++++++++ .../lib/useCanonicalManagedAgentProfile.ts | 117 ++++++- .../ui/ProfileInstancesArchived.test.mjs | 149 +++++++++ .../profile/ui/ProfileInstancesSection.tsx | 121 +++++++ .../features/profile/ui/UserProfilePanel.tsx | 4 +- .../profile/ui/UserProfilePanelSections.tsx | 10 +- .../profile/ui/UserProfilePanelTabs.tsx | 66 +--- 14 files changed, 1087 insertions(+), 114 deletions(-) create mode 100644 desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs create mode 100644 desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs create mode 100644 desktop/src/features/profile/lib/bucketPersonaInstances.test.mjs create mode 100644 desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs create mode 100644 desktop/src/features/profile/ui/ProfileInstancesArchived.test.mjs create mode 100644 desktop/src/features/profile/ui/ProfileInstancesSection.tsx diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index 8fbd7a3bfb1..710b5fc4be8 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -6,20 +6,67 @@ import { pickProfileAgent, } from "./pickProfileAgent.ts"; +const NONE_ARCHIVED = () => false; + +function agent(overrides = {}) { + return { + name: "Instance", + pubkey: "a".repeat(64), + status: "stopped", + ...overrides, + }; +} + test("the shared profile target prefers the active persona instance", () => { - const stopped = { + const stopped = agent({ name: "Earlier instance", pubkey: "a".repeat(64), status: "stopped", - }; - const running = { + }); + const running = agent({ name: "Current instance", pubkey: "b".repeat(64), status: "running", - }; + }); - assert.equal(pickProfileAgent([stopped, running]), running); - assert.equal(pickProfileAgent([running, stopped]), running); + assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running); + assert.equal(pickProfileAgent([running, stopped], NONE_ARCHIVED), running); +}); + +test("an archived instance early in file order cannot hijack the target", () => { + const archived = agent({ + name: "Archived instance", + pubkey: "a".repeat(64), + status: "running", + }); + const live = agent({ + name: "Live instance", + pubkey: "b".repeat(64), + status: "stopped", + }); + const isArchived = (pubkey) => pubkey === archived.pubkey; + + // Archived is active AND first — without the filter it would win the sort. + assert.equal(pickProfileAgent([archived, live], isArchived), live); + assert.equal(pickProfileAgent([live, archived], isArchived), live); +}); + +test("all instances archived yields undefined for persona-only mode", () => { + const first = agent({ pubkey: "a".repeat(64) }); + const second = agent({ pubkey: "b".repeat(64) }); + + assert.equal( + pickProfileAgent([first, second], () => true), + undefined, + ); +}); + +test("a fail-open predicate keeps every instance eligible while loading", () => { + const stopped = agent({ pubkey: "a".repeat(64), status: "stopped" }); + const running = agent({ pubkey: "b".repeat(64), status: "running" }); + + // Fail-open (all false) during the archive-snapshot window: normal ranking. + assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running); }); test("a direct-opened active instance is never redirected to a sibling", () => { @@ -36,7 +83,10 @@ test("a direct-opened active instance is never redirected to a sibling", () => { status: "running", }; - assert.equal(pickDirectProfileAgent(clicked, [sibling, clicked]), clicked); + assert.equal( + pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED), + clicked, + ); }); test("a direct-opened inactive instance redirects to the active sibling", () => { @@ -52,7 +102,7 @@ test("a direct-opened inactive instance redirects to the active sibling", () => }; assert.equal( - pickDirectProfileAgent(historical, [historical, current]), + pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED), current, ); }); @@ -70,8 +120,8 @@ test("a direct-opened inactive instance with no active sibling stays put", () => }; assert.equal( - pickDirectProfileAgent(clicked, [clicked, otherStopped]), + pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED), clicked, ); - assert.equal(pickDirectProfileAgent(clicked, []), clicked); + assert.equal(pickDirectProfileAgent(clicked, [], NONE_ARCHIVED), clicked); }); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts index cea746f10cd..dc2437c86ea 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -7,14 +7,26 @@ import type { ManagedAgent } from "@/shared/api/types"; * A persona can have several historical agent instances. Keeping this rule in * one place prevents an avatar click on an older message from opening a * different detail surface than the card in the Agents library. + * + * Relay-archived instances are never eligible, so an archived record early in + * file order can't hijack the persona target. Returns `undefined` when every + * instance is archived — the card then renders in persona-only mode. The + * `isArchived` predicate is fail-open (returns `false` while the relay archive + * snapshot loads), so a cold start never briefly picks nothing. */ -export function pickProfileAgent(agents: readonly ManagedAgent[]) { - return [...agents].sort((left, right) => { - const activeDiff = - Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left)); - if (activeDiff !== 0) return activeDiff; - return left.name.localeCompare(right.name); - })[0]; +export function pickProfileAgent( + agents: readonly ManagedAgent[], + isArchived: (pubkey: string) => boolean, +) { + return [...agents] + .filter((agent) => !isArchived(agent.pubkey)) + .sort((left, right) => { + const activeDiff = + Number(isManagedAgentActive(right)) - + Number(isManagedAgentActive(left)); + if (activeDiff !== 0) return activeDiff; + return left.name.localeCompare(right.name); + })[0]; } /** @@ -26,13 +38,15 @@ export function pickProfileAgent(agents: readonly ManagedAgent[]) { * "tighten access" save widen the wrong agent. But when the clicked instance * is inactive and the persona has an active instance elsewhere (an avatar on * an old message from a retired instance), redirect to the active one so the - * panel matches the Agents library. + * panel matches the Agents library. The `isArchived` predicate keeps that + * redirect from ever landing on an archived sibling. */ export function pickDirectProfileAgent( directAgent: ManagedAgent, personaInstances: readonly ManagedAgent[], + isArchived: (pubkey: string) => boolean, ) { if (isManagedAgentActive(directAgent)) return directAgent; - const canonical = pickProfileAgent(personaInstances); + const canonical = pickProfileAgent(personaInstances, isArchived); return canonical && isManagedAgentActive(canonical) ? canonical : directAgent; } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index c6b1821ce1d..d0ff2e2738a 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -9,6 +9,7 @@ import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModel import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; @@ -94,9 +95,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onDeletePersona, } = props; + const isArchived = useIsArchivedPredicate(); const { groups, ungrouped, unknown } = React.useMemo( - () => buildUnifiedGroups(personas, agents), - [personas, agents], + () => buildUnifiedGroups(personas, agents, isArchived), + [personas, agents, isArchived], ); const [collapsed, setCollapsed] = React.useState>(new Set()); function toggle(key: string) { @@ -129,7 +131,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onClick={onOpenCatalog} /> {groups.map((group) => { - const profileAgent = pickProfileAgent(group.agents); + const profileAgent = pickProfileAgent(group.agents, isArchived); return ( ( @@ -265,7 +267,6 @@ function AgentPersonaCard({ const friendlyError = agent ? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy : null; - const opensRuntimeTab = Boolean(agent && friendlyError && !isActive); return ( { - if (agent) { - onOpenAgentProfile( - agent.pubkey, - opensRuntimeTab ? { tab: "runtime" } : undefined, - ); - return; - } + // The card's main click always opens the PERSONA target, never an + // explicit pubkey. A pubkey target is durable in the panel, so a pick + // made during the archive-snapshot fail-open window would strand the + // panel on an archived identity after hydration (Carl's cold-hydration + // race). A persona target re-resolves every render through the shared + // archive-aware selector, so it self-corrects to a live sibling — or + // persona-only mode when every instance is archived. Deliberate + // instance navigation and the runtime-error affordance keep their + // explicit-pubkey path via the avatar control below. onOpenPersonaProfile(persona); }} statusBadge={ diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs b/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs new file mode 100644 index 00000000000..690a921040e --- /dev/null +++ b/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs @@ -0,0 +1,295 @@ +/** + * Rule 1 regression: the persona card's MAIN click records a PERSONA target, + * never an explicit pubkey — even during the archive-snapshot fail-open window, + * when pickProfileAgent transiently selects an archived sibling. + * + * Why a mounted render test rather than a pure resolver test: + * resolveCanonicalManagedAgent (unit-tested separately) proves a persona + * target self-corrects to the live sibling after hydration — but it assumes + * the card emits a persona target. The defect being closed is the card + * emitting a durable *pubkey* target that survives hydration. Only mounting + * the real card and firing its main click catches a mutation that reverts + * onClick back to onOpenAgentProfile(agent.pubkey). AgentPersonaCard is + * module-local, so the whole section is mounted. + * + * Fail-open is reproduced faithfully: the list_archived_identities IPC call + * never settles, so useIsArchivedPredicate returns all-live at click time and + * pickProfileAgent selects the archived-first sibling — exactly the transient + * window the durable pubkey target used to strand the panel on. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +// Track every client so afterEach can drop cached queries. A query left pending +// (the fail-open archive snapshot) plus react-query's default gcTime schedules +// timers that outlive the test and stall the shared `pnpm test` process. +const clients = []; + +let act; +let cleanup; +let fireEvent; +let render; +let screen; +let createElement; +let QueryClient; +let QueryClientProvider; +let UnifiedAgentsSection; + +const ipcHandlers = new Map(); + +const SELF_PK = "c".repeat(64); +const ARCHIVED_PK = "a".repeat(64); +const LIVE_PK = "b".repeat(64); + +function agent(overrides = {}) { + return { + pubkey: LIVE_PK, + name: "Instance", + personaId: "persona-1", + status: "stopped", + model: null, + modelSource: "global", + lastError: null, + lastErrorCode: null, + needsRestart: false, + personaOrphaned: false, + ...overrides, + }; +} + +function persona(overrides = {}) { + return { + id: "persona-1", + displayName: "Fizz Prime", + avatarUrl: null, + model: null, + isBuiltIn: false, + sourceTeam: null, + ...overrides, + }; +} + +function baseProps(overrides = {}) { + return { + defaultModel: "gpt-x", + actionErrorMessage: null, + actionNoticeMessage: null, + agents: [], + agentsError: null, + isActionPending: false, + isAgentsLoading: false, + restartingAgentPubkey: null, + startingAgentPubkey: null, + startingPersonaIds: new Set(), + onOpenAgentProfile: () => {}, + onOpenPersonaProfile: () => {}, + onRestartAgent: () => {}, + onStartAgent: () => {}, + onStartPersona: () => {}, + personas: [], + personasError: null, + personaFeedbackErrorMessage: null, + personaFeedbackNoticeMessage: null, + isPersonasLoading: false, + isPersonasPending: false, + onOpenCatalog: () => {}, + onDuplicatePersona: () => {}, + onEditPersona: () => {}, + onSharePersona: () => {}, + onDeactivatePersona: () => {}, + onDeletePersona: () => {}, + ...overrides, + }; +} + +function renderSection(props) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + clients.push(client); + return render( + createElement( + QueryClientProvider, + { client }, + createElement(UnifiedAgentsSection, props), + ), + ); +} + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; + + ({ act, cleanup, fireEvent, render, screen } = await import( + "@testing-library/react" + )); + ({ createElement } = await import("react")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ UnifiedAgentsSection } = await import("./UnifiedAgentsSection.tsx")); +}); + +afterEach(() => { + cleanup?.(); + for (const client of clients.splice(0)) { + client.cancelQueries(); + client.clear(); + } + ipcHandlers.clear(); +}); + +after(() => dom.window.close()); + +function installFailOpenIpc() { + ipcHandlers.set("get_identity", () => + Promise.resolve({ pubkey: SELF_PK, display_name: "Me" }), + ); + // Never resolves: the archive snapshot stays loading, so the predicate is + // fail-open (treats every identity as live) for the whole test. + ipcHandlers.set("list_archived_identities", () => new Promise(() => {})); + ipcHandlers.set("get_user_profile", () => + Promise.resolve({ + pubkey: LIVE_PK, + display_name: null, + avatar_url: null, + about: null, + nip05_handle: null, + owner_pubkey: null, + }), + ); +} + +test("persona card main click records a persona target, never an explicit pubkey", async () => { + installFailOpenIpc(); + + let recordedPersona; + const onOpenAgentProfile = () => { + throw new Error("card main click must not open an explicit pubkey target"); + }; + const onOpenPersonaProfile = (persona) => { + recordedPersona = persona; + }; + + // Archived sibling sorts first by name, so under fail-open pickProfileAgent + // selects it — the card displays the archived identity at click time. A + // durable pubkey target would strand the panel there after hydration. + const agents = [ + agent({ pubkey: ARCHIVED_PK, name: "Archived Sibling" }), + agent({ pubkey: LIVE_PK, name: "Zed Sibling" }), + ]; + + await act(async () => { + renderSection( + baseProps({ + agents, + personas: [persona()], + onOpenAgentProfile, + onOpenPersonaProfile, + }), + ); + }); + + fireEvent.click( + screen.getByRole("button", { name: "Fizz Prime agent profile" }), + ); + + assert.ok(recordedPersona, "the click must record a persona target"); + assert.equal(recordedPersona.id, "persona-1"); +}); + +test("persona card main click records a persona target even for a stopped errored agent", async () => { + installFailOpenIpc(); + + let recordedPersona; + await act(async () => { + renderSection( + baseProps({ + agents: [ + agent({ + pubkey: LIVE_PK, + name: "Errored", + status: "stopped", + lastError: "boom", + }), + ], + personas: [persona()], + onOpenAgentProfile: () => { + throw new Error("main click must not open an explicit pubkey target"); + }, + onOpenPersonaProfile: (persona) => { + recordedPersona = persona; + }, + }), + ); + }); + + fireEvent.click( + screen.getByRole("button", { name: "Fizz Prime agent profile" }), + ); + + assert.equal(recordedPersona?.id, "persona-1"); +}); + +test("errored avatar affordance still opens the explicit pubkey on the runtime tab", async () => { + installFailOpenIpc(); + + const opened = []; + await act(async () => { + renderSection( + baseProps({ + agents: [ + agent({ + pubkey: LIVE_PK, + name: "Errored", + status: "stopped", + lastError: "boom", + }), + ], + personas: [persona()], + onOpenAgentProfile: (pubkey, options) => { + opened.push({ pubkey, options }); + }, + onOpenPersonaProfile: () => { + throw new Error("the error affordance must open the explicit pubkey"); + }, + }), + ); + }); + + // The error badge is the deliberate explicit-pubkey path preserved for + // manage/diagnose access; it is the reserved instance/error navigation that + // rule 1 keeps valid, unchanged by the main-click fix. + fireEvent.click(screen.getByTestId(`agent-runtime-error-${LIVE_PK}`)); + + assert.deepEqual(opened, [{ pubkey: LIVE_PK, options: { tab: "runtime" } }]); +}); diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs b/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs new file mode 100644 index 00000000000..b3ade7f229b --- /dev/null +++ b/desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildUnifiedGroups } from "./unifiedAgentGroups.ts"; + +const NONE_ARCHIVED = () => false; + +function agent(overrides = {}) { + return { + name: "Agent", + pubkey: "a".repeat(64), + personaId: null, + status: "stopped", + ...overrides, + }; +} + +function persona(overrides = {}) { + return { id: "persona-1", displayName: "Persona", ...overrides }; +} + +test("archived standalone custom agents are omitted while live peers remain", () => { + const archived = agent({ pubkey: "a".repeat(64), personaId: null }); + const live = agent({ pubkey: "b".repeat(64), personaId: null }); + const isArchived = (pubkey) => pubkey === archived.pubkey; + + const { ungrouped } = buildUnifiedGroups([], [archived, live], isArchived); + + assert.deepEqual( + ungrouped.map((agent) => agent.pubkey), + [live.pubkey], + ); +}); + +test("archived unknown-persona agents are omitted while live peers remain", () => { + const archived = agent({ pubkey: "a".repeat(64), personaId: "orphan" }); + const live = agent({ pubkey: "b".repeat(64), personaId: "orphan" }); + const isArchived = (pubkey) => pubkey === archived.pubkey; + + // No persona matches "orphan", so both land in the unknown bucket. + const { unknown } = buildUnifiedGroups([], [archived, live], isArchived); + + assert.deepEqual( + unknown.map((agent) => agent.pubkey), + [live.pubkey], + ); +}); + +test("matched persona groups keep their full instance list including archived", () => { + const archived = agent({ pubkey: "a".repeat(64), personaId: "persona-1" }); + const live = agent({ pubkey: "b".repeat(64), personaId: "persona-1" }); + const isArchived = (pubkey) => pubkey === archived.pubkey; + + // The card resolves its own target via pickProfileAgent; the group keeps the + // archived record so an all-archived persona still forms a card in + // persona-only mode rather than vanishing from the library. + const { groups } = buildUnifiedGroups( + [persona()], + [archived, live], + isArchived, + ); + + assert.equal(groups.length, 1); + assert.deepEqual( + groups[0].agents.map((agent) => agent.pubkey).sort(), + [archived.pubkey, live.pubkey].sort(), + ); +}); + +test("a fail-open predicate keeps every standalone agent discoverable", () => { + const first = agent({ pubkey: "a".repeat(64), personaId: null }); + const second = agent({ pubkey: "b".repeat(64), personaId: null }); + + const { ungrouped } = buildUnifiedGroups([], [first, second], NONE_ARCHIVED); + + assert.equal(ungrouped.length, 2); +}); diff --git a/desktop/src/features/agents/ui/unifiedAgentGroups.ts b/desktop/src/features/agents/ui/unifiedAgentGroups.ts index 60c44f9292a..2ddf34d8402 100644 --- a/desktop/src/features/agents/ui/unifiedAgentGroups.ts +++ b/desktop/src/features/agents/ui/unifiedAgentGroups.ts @@ -2,16 +2,28 @@ import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] }; +/** + * Group managed agents under their personas for the Agents library. + * + * Archived instances are dropped from the standalone `ungrouped` (custom + * agents) and `unknown` buckets so a relay-archived identity never shows as a + * clickable library card of its own. Matched persona groups keep their full + * instance list — the persona card resolves its own target through + * `pickProfileAgent`, which applies the same `isArchived` filter and falls back + * to persona-only mode when every instance is archived. `isArchived` is + * fail-open (returns `false` while the relay archive snapshot loads). + */ export function buildUnifiedGroups( personas: AgentPersona[], agents: ManagedAgent[], + isArchived: (pubkey: string) => boolean, ) { const byPersonaId = new Map(); const ungrouped: ManagedAgent[] = []; for (const agent of agents) { if (!agent.personaId) { - ungrouped.push(agent); + if (!isArchived(agent.pubkey)) ungrouped.push(agent); } else { const list = byPersonaId.get(agent.personaId) ?? []; list.push(agent); @@ -27,7 +39,9 @@ export function buildUnifiedGroups( const unknown: ManagedAgent[] = []; for (const [id, list] of byPersonaId) { - if (!matched.has(id)) unknown.push(...list); + if (!matched.has(id)) { + unknown.push(...list.filter((agent) => !isArchived(agent.pubkey))); + } } return { groups, ungrouped, unknown }; diff --git a/desktop/src/features/profile/lib/bucketPersonaInstances.test.mjs b/desktop/src/features/profile/lib/bucketPersonaInstances.test.mjs new file mode 100644 index 00000000000..396c7a9f7e4 --- /dev/null +++ b/desktop/src/features/profile/lib/bucketPersonaInstances.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { bucketPersonaInstances } from "./useCanonicalManagedAgentProfile.ts"; + +const LIVE_PK = "b".repeat(64); +const SECOND_LIVE_PK = "c".repeat(64); +const ARCHIVED_PK = "a".repeat(64); + +function agent(overrides = {}) { + return { + name: "Instance", + pubkey: LIVE_PK, + personaId: "persona-1", + status: "stopped", + ...overrides, + }; +} + +test("mixed roster splits archived from live, preserving order", () => { + const archived = agent({ pubkey: ARCHIVED_PK }); + const live = agent({ pubkey: LIVE_PK }); + const secondLive = agent({ pubkey: SECOND_LIVE_PK }); + + const { live: liveBucket, archived: archivedBucket } = bucketPersonaInstances( + [archived, live, secondLive], + (pubkey) => pubkey === ARCHIVED_PK, + ); + + assert.deepEqual(liveBucket, [live, secondLive]); + assert.deepEqual(archivedBucket, [archived]); +}); + +test("an all-archived persona puts every instance in the archived bucket", () => { + const first = agent({ pubkey: ARCHIVED_PK }); + const second = agent({ pubkey: LIVE_PK }); + + const { live, archived } = bucketPersonaInstances( + [first, second], + () => true, + ); + + assert.deepEqual(live, []); + assert.deepEqual(archived, [first, second]); +}); + +test("fail-open while loading keeps every instance live", () => { + // The predicate returns `false` for all pubkeys until the archive snapshot + // loads; nothing is bucketed as archived, so nothing is labeled or hidden. + const archived = agent({ pubkey: ARCHIVED_PK }); + const live = agent({ pubkey: LIVE_PK }); + + const { live: liveBucket, archived: archivedBucket } = bucketPersonaInstances( + [archived, live], + () => false, + ); + + assert.deepEqual(liveBucket, [archived, live]); + assert.deepEqual(archivedBucket, []); +}); diff --git a/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs b/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs new file mode 100644 index 00000000000..072b8ccd231 --- /dev/null +++ b/desktop/src/features/profile/lib/resolveCanonicalManagedAgent.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveCanonicalManagedAgent } from "./useCanonicalManagedAgentProfile.ts"; + +const LIVE_PK = "b".repeat(64); +const ARCHIVED_PK = "a".repeat(64); +const HISTORICAL_PK = "c".repeat(64); +const NONE_ARCHIVED = () => false; + +function agent(overrides = {}) { + return { + name: "Instance", + pubkey: LIVE_PK, + personaId: "persona-1", + status: "stopped", + ...overrides, + }; +} + +test("a persona target with a live sibling resolves to the live instance", () => { + const archived = agent({ pubkey: ARCHIVED_PK, status: "running" }); + const live = agent({ pubkey: LIVE_PK, status: "stopped" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: undefined, + isArchived: (pubkey) => pubkey === ARCHIVED_PK, + personaInstances: [archived, live], + preferDirectManagedAgent: false, + preserveRequestedInstance: false, + pubkey: undefined, + }); + + assert.equal(resolved, live); +}); + +test("a persona target with all instances archived resolves to undefined", () => { + const first = agent({ pubkey: ARCHIVED_PK }); + const second = agent({ pubkey: HISTORICAL_PK }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: undefined, + isArchived: () => true, + personaInstances: [first, second], + preferDirectManagedAgent: false, + preserveRequestedInstance: false, + pubkey: undefined, + }); + + assert.equal(resolved, undefined); +}); + +test("an explicit archived pubkey stays exact even when a live sibling exists", () => { + const archivedDirect = agent({ pubkey: ARCHIVED_PK }); + const live = agent({ pubkey: LIVE_PK, status: "running" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: archivedDirect, + isArchived: (pubkey) => pubkey === ARCHIVED_PK, + personaInstances: [archivedDirect, live], + preferDirectManagedAgent: false, + preserveRequestedInstance: false, + pubkey: ARCHIVED_PK, + }); + + // Without the exactness short-circuit the selector would drop the archived + // record and return `live`, stranding the unarchive controller. + assert.equal(resolved, archivedDirect); +}); + +test("an explicit archived pubkey with no managed record resolves to undefined so the panel keeps the requested key", () => { + // A historical archived pubkey with no current managed record: directManaged + // is undefined, and the panel falls back to the requested pubkey verbatim. + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: undefined, + isArchived: (pubkey) => pubkey === HISTORICAL_PK, + personaInstances: [], + preferDirectManagedAgent: false, + preserveRequestedInstance: false, + pubkey: HISTORICAL_PK, + }); + + assert.equal(resolved, undefined); +}); + +test("a preserved requested instance pins the exact record over canonicalization", () => { + const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" }); + const live = agent({ pubkey: LIVE_PK, status: "running" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: requested, + isArchived: NONE_ARCHIVED, + personaInstances: [requested, live], + preferDirectManagedAgent: false, + preserveRequestedInstance: true, + pubkey: HISTORICAL_PK, + }); + + assert.equal(resolved, requested); +}); + +test("a non-archived historical pubkey canonicalizes to the live persona instance", () => { + // Rule 5: #5788 canonicalization is retained for non-archived navigation. + const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" }); + const live = agent({ pubkey: LIVE_PK, status: "running" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: requested, + isArchived: NONE_ARCHIVED, + personaInstances: [requested, live], + preferDirectManagedAgent: false, + preserveRequestedInstance: false, + pubkey: HISTORICAL_PK, + }); + + assert.equal(resolved, live); +}); + +test("preferDirectManagedAgent keeps a directly opened active instance exact", () => { + // The panel's own default: an access edit must target the clicked instance, + // not an alphabetically-earlier active sibling. + const sibling = agent({ name: "Alpha", pubkey: LIVE_PK, status: "running" }); + const clicked = agent({ + name: "Zulu", + pubkey: HISTORICAL_PK, + status: "running", + }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: clicked, + isArchived: NONE_ARCHIVED, + personaInstances: [sibling, clicked], + preferDirectManagedAgent: true, + preserveRequestedInstance: false, + pubkey: HISTORICAL_PK, + }); + + assert.equal(resolved, clicked); +}); + +test("an explicit archived pubkey stays exact even with preferDirectManagedAgent", () => { + // Rule 2 wins over the direct-preference redirect: a deliberately opened + // archived instance must not be redirected away from its unarchive control. + const archivedDirect = agent({ pubkey: ARCHIVED_PK, status: "stopped" }); + const live = agent({ pubkey: LIVE_PK, status: "running" }); + + const resolved = resolveCanonicalManagedAgent({ + directManagedAgent: archivedDirect, + isArchived: (pubkey) => pubkey === ARCHIVED_PK, + personaInstances: [archivedDirect, live], + preferDirectManagedAgent: true, + preserveRequestedInstance: false, + pubkey: ARCHIVED_PK, + }); + + assert.equal(resolved, archivedDirect); +}); diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts index 6785640a7f6..0393a795ce3 100644 --- a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts @@ -4,12 +4,83 @@ import { pickDirectProfileAgent, pickProfileAgent, } from "@/features/agents/lib/pickProfileAgent"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserProfileQuery } from "@/features/profile/hooks"; import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId"; import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +/** + * Resolve the single managed instance a profile surface represents, honouring + * the archive-aware target-provenance rules. Pure so the resolution matrix is + * testable without mounting the panel; the hook supplies the live inputs. + * + * - `preserveRequestedInstance` + a direct match pins that exact record (an + * explicit Runtime → Instances selection). + * - A deliberately requested archived pubkey stays EXACT — exactness beats + * canonicalization iff the requested pubkey is archived — so its archive + * controller can unarchive that identity even when a live sibling exists. + * Returns the managed record when one exists; otherwise `undefined`, so the + * panel falls back to the requested pubkey verbatim (a historical archived + * key with no current managed record still resolves to itself). + * - `preferDirectManagedAgent` (the panel's own default) keeps a directly + * opened active instance exact so an access edit targets it, only redirecting + * an inactive click to a live sibling — see `pickDirectProfileAgent`. + * - Otherwise persona-target and non-archived historical navigation resolve + * through the shared archive-aware selector: all instances archived yields + * `undefined` (persona-only mode), else the canonical live instance. + */ +export function resolveCanonicalManagedAgent(input: { + directManagedAgent: ManagedAgent | undefined; + isArchived: (pubkey: string) => boolean; + personaInstances: readonly ManagedAgent[]; + preferDirectManagedAgent: boolean; + preserveRequestedInstance: boolean; + pubkey: string | undefined; +}): ManagedAgent | undefined { + const { + directManagedAgent, + isArchived, + personaInstances, + preferDirectManagedAgent, + preserveRequestedInstance, + pubkey, + } = input; + if (preserveRequestedInstance && directManagedAgent) { + return directManagedAgent; + } + if (pubkey && isArchived(pubkey)) { + return directManagedAgent; + } + if (preferDirectManagedAgent && directManagedAgent) { + return pickDirectProfileAgent( + directManagedAgent, + personaInstances, + isArchived, + ); + } + return pickProfileAgent(personaInstances, isArchived) ?? directManagedAgent; +} + +/** + * Split a persona's instances into live and archived buckets off the same + * archive predicate the selector uses — one policy, no duplication. Fail-open + * is inherited: while the archive snapshot loads `isArchived` returns `false`, + * so every instance lands in `live` and nothing is labeled or hidden. + */ +export function bucketPersonaInstances( + personaInstances: readonly ManagedAgent[], + isArchived: (pubkey: string) => boolean, +): { live: ManagedAgent[]; archived: ManagedAgent[] } { + const live: ManagedAgent[] = []; + const archived: ManagedAgent[] = []; + for (const instance of personaInstances) { + (isArchived(instance.pubkey) ? archived : live).push(instance); + } + return { live, archived }; +} + export function useCanonicalManagedAgentProfile(input: { currentPubkey: string | undefined; managedAgents: readonly ManagedAgent[] | undefined; @@ -53,20 +124,36 @@ export function useCanonicalManagedAgentProfile(input: { (agent) => agent.personaId === linkedPersonaId, ); }, [directManagedAgent, linkedPersonaId, managedAgents]); - const managedAgent = React.useMemo(() => { - if (directManagedAgent) { - if (preserveRequestedInstance) return directManagedAgent; - if (preferDirectManagedAgent) { - return pickDirectProfileAgent(directManagedAgent, personaInstances); - } - } - return pickProfileAgent(personaInstances) ?? directManagedAgent; - }, [ - directManagedAgent, - personaInstances, - preferDirectManagedAgent, - preserveRequestedInstance, - ]); + const isArchived = useIsArchivedPredicate(); + const managedAgent = React.useMemo( + () => + resolveCanonicalManagedAgent({ + directManagedAgent, + isArchived, + personaInstances, + preferDirectManagedAgent, + preserveRequestedInstance, + pubkey, + }), + [ + directManagedAgent, + isArchived, + personaInstances, + preferDirectManagedAgent, + preserveRequestedInstance, + pubkey, + ], + ); + // Split the roster for the Instances list off the same predicate the selector + // uses — see `bucketPersonaInstances` for the fail-open semantics. + const instanceBuckets = React.useMemo( + () => bucketPersonaInstances(personaInstances, isArchived), + [isArchived, personaInstances], + ); - return { linkedPersonaId, managedAgent, personaInstances }; + return { + instanceBuckets, + linkedPersonaId, + managedAgent, + }; } diff --git a/desktop/src/features/profile/ui/ProfileInstancesArchived.test.mjs b/desktop/src/features/profile/ui/ProfileInstancesArchived.test.mjs new file mode 100644 index 00000000000..3a20330bfa2 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileInstancesArchived.test.mjs @@ -0,0 +1,149 @@ +/** + * Archived-instances subsection regression: the profile panel's Instances list + * splits live and relay-archived siblings, rendering archived rows under a + * clearly labeled "Archived" header so unarchive stays UI-reachable for + * channel-less agents. The section appears whenever live OR archived instances + * exist, self-omits when neither does, shows only the Archived subsection for + * an all-archived persona, and archived rows keep the deliberate explicit- + * pubkey click that feeds selector matrix rule 3. + * + * Mounts the shipping ProfileInstancesSection (owned by the Runtime tab) and + * drives the real expand toggle rather than reimplementing it. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +let cleanup; +let fireEvent; +let render; +let screen; +let createElement; +let ProfileInstancesSection; + +const LIVE_PK = "b".repeat(64); +const SECOND_LIVE_PK = "c".repeat(64); +const ARCHIVED_PK = "a".repeat(64); + +function agent(overrides = {}) { + return { + pubkey: LIVE_PK, + name: "Instance", + personaId: "persona-1", + status: "stopped", + ...overrides, + }; +} + +function baseProps(overrides = {}) { + return { + currentPubkey: null, + instances: [], + archivedInstances: [], + onOpenInstance: () => {}, + ...overrides, + }; +} + +function renderSection(props) { + return render(createElement(ProfileInstancesSection, baseProps(props))); +} + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + }); + + ({ cleanup, fireEvent, render, screen } = await import( + "@testing-library/react" + )); + ({ createElement } = await import("react")); + ({ ProfileInstancesSection } = await import("./ProfileInstancesSection.tsx")); +}); + +afterEach(() => cleanup?.()); +after(() => dom.window.close()); + +test("test_archived_and_live_instances_render_archived_subsection", () => { + renderSection({ + instances: [agent({ pubkey: LIVE_PK, name: "Live" })], + archivedInstances: [agent({ pubkey: ARCHIVED_PK, name: "Archived one" })], + }); + fireEvent.click(screen.getByTestId("user-profile-instances")); + + assert.equal( + screen.getByTestId("user-profile-instances").textContent, + "2 instances", + ); + assert.ok(screen.getByTestId("user-profile-instances-archived-header")); + assert.ok(screen.getByTestId(`user-profile-instance-${LIVE_PK}`)); + assert.ok(screen.getByTestId(`user-profile-instance-${ARCHIVED_PK}`)); +}); + +test("test_no_archived_instances_omits_archived_subsection", () => { + renderSection({ + instances: [ + agent({ pubkey: LIVE_PK, name: "Live" }), + agent({ pubkey: SECOND_LIVE_PK, name: "Live two" }), + ], + archivedInstances: [], + }); + fireEvent.click(screen.getByTestId("user-profile-instances")); + + assert.equal(screen.queryByTestId("user-profile-instances-archived"), null); + assert.ok(screen.getByTestId(`user-profile-instance-${LIVE_PK}`)); +}); + +test("test_all_archived_persona_shows_only_archived_subsection", () => { + renderSection({ + instances: [], + archivedInstances: [agent({ pubkey: ARCHIVED_PK, name: "Archived only" })], + }); + // Section renders even with an empty live list. + fireEvent.click(screen.getByTestId("user-profile-instances")); + + assert.equal( + screen.getByTestId("user-profile-instances").textContent, + "1 instance", + ); + assert.ok(screen.getByTestId("user-profile-instances-archived-header")); + assert.ok(screen.getByTestId(`user-profile-instance-${ARCHIVED_PK}`)); +}); + +test("test_no_instances_at_all_omits_instances_section", () => { + renderSection({ instances: [], archivedInstances: [] }); + assert.equal(screen.queryByTestId("user-profile-instances-section"), null); +}); + +test("test_archived_row_click_opens_that_explicit_pubkey", () => { + const opened = []; + renderSection({ + instances: [], + archivedInstances: [agent({ pubkey: ARCHIVED_PK, name: "Archived only" })], + onOpenInstance: (pubkey) => opened.push(pubkey), + }); + fireEvent.click(screen.getByTestId("user-profile-instances")); + fireEvent.click(screen.getByTestId(`user-profile-instance-${ARCHIVED_PK}`)); + + // The archived row keeps the deliberate explicit-pubkey path (unarchive). + assert.deepEqual(opened, [ARCHIVED_PK]); +}); diff --git a/desktop/src/features/profile/ui/ProfileInstancesSection.tsx b/desktop/src/features/profile/ui/ProfileInstancesSection.tsx new file mode 100644 index 00000000000..63c5e546e84 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileInstancesSection.tsx @@ -0,0 +1,121 @@ +import * as React from "react"; +import { ChevronRight } from "lucide-react"; + +import { ProfileSectionGroup } from "@/features/profile/ui/UserProfilePanelFields"; +import type { ManagedAgent } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +function ProfileInstanceRow({ + archived = false, + currentPubkey, + instance, + onOpenInstance, +}: { + archived?: boolean; + currentPubkey: string | null; + instance: ManagedAgent; + onOpenInstance: (pubkey: string) => void; +}) { + const isCurrent = instance.pubkey === currentPubkey; + return ( + + ); +} + +/** + * The persona's managed-agent instances, split into live rows and a labeled + * "Archived" subsection. Archived rows keep the explicit-pubkey click so + * unarchive stays UI-reachable for channel-less agents — the deliberate- + * navigation path (selector matrix rule 3) that lets a click land on the exact + * archived identity. The count reflects both buckets; the section renders only + * when at least one instance (live or archived) exists. + */ +export function ProfileInstancesSection({ + archivedInstances, + currentPubkey, + instances, + onOpenInstance, +}: { + archivedInstances: ManagedAgent[]; + currentPubkey: string | null; + instances: ManagedAgent[]; + onOpenInstance: (pubkey: string) => void; +}) { + const [expanded, setExpanded] = React.useState(false); + const totalCount = instances.length + archivedInstances.length; + if (totalCount === 0) return null; + const instanceCountLabel = `${totalCount} instance${totalCount === 1 ? "" : "s"}`; + + return ( + + + {expanded ? ( + <> + {instances.map((instance) => ( + + ))} + {archivedInstances.length > 0 ? ( +
+

+ Archived +

+ {archivedInstances.map((instance) => ( + + ))} +
+ ) : null} + + ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 7638b0cb367..f02b5aee097 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -189,7 +189,7 @@ export function UserProfilePanel({ ); const personasQuery = usePersonasQuery(); const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); - const { linkedPersonaId, managedAgent, personaInstances } = + const { instanceBuckets, linkedPersonaId, managedAgent } = useCanonicalManagedAgentProfile({ currentPubkey, managedAgents: managedAgentsQuery.data, @@ -812,7 +812,7 @@ export function UserProfilePanel({ isFollowing={isFollowing} isOwner={viewerIsOwner} isSelf={isSelf} - instances={personaInstances} + instanceBuckets={instanceBuckets} activityAgent={activityAgent} managedAgent={managedAgent} agentInfoFields={agentInfoFields} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index dbe17473f65..0582e539652 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -87,7 +87,7 @@ export type ProfileSummaryViewProps = { isFollowing: boolean; isOwner: boolean | undefined; isSelf: boolean; - instances: ManagedAgent[]; + instanceBuckets: { live: ManagedAgent[]; archived: ManagedAgent[] }; managedAgent: ManagedAgent | undefined; agentInfoFields: ProfileField[]; archiveActions: IdentityArchiveActions; @@ -163,7 +163,7 @@ export function ProfileSummaryView({ isFollowing, isOwner, isSelf, - instances, + instanceBuckets, managedAgent, agentInfoFields, archiveActions, @@ -233,7 +233,8 @@ export function ProfileSummaryView({ (managedAgent !== undefined || runtimeConfigurationFields.length > 0 || runtimeSettingsFields.length > 0 || - instances.length > 0 || + instanceBuckets.live.length > 0 || + instanceBuckets.archived.length > 0 || diagnosticsFields.length > 0 || canOpenAgentLogs); const showDiagnosticsIngress = @@ -535,7 +536,8 @@ export function ProfileSummaryView({ diagnosticsFields={diagnosticsFields} diagnosticsSummary={diagnosticsTrailing} configurationFields={runtimeFields} - instances={instances} + instances={instanceBuckets.live} + archivedInstances={instanceBuckets.archived} modelSettings={ isOwner === true && managedAgent !== undefined ? ( void; -}) { - const [expanded, setExpanded] = React.useState(false); - const instanceCountLabel = `${instances.length} instance${instances.length === 1 ? "" : "s"}`; - - return ( - - - {expanded - ? instances.map((instance) => { - const isCurrent = instance.pubkey === currentPubkey; - return ( - - ); - }) - : null} - - ); -} - function ProfileLiveActivityEmbed({ activeTurns, activityAgent, @@ -765,6 +706,7 @@ function ArchiveStatusTooltip() { export function ProfileRuntimeTabContent({ autoRestartEnabled = false, + archivedInstances, currentPubkey, diagnosticsFields, diagnosticsSummary, @@ -782,6 +724,7 @@ export function ProfileRuntimeTabContent({ }: { /** Whether the per-agent auto-restart toggle is ON. */ autoRestartEnabled?: boolean; + archivedInstances: ManagedAgent[]; currentPubkey: string | null; diagnosticsFields: ProfileField[]; diagnosticsSummary: React.ReactNode; @@ -822,7 +765,7 @@ export function ProfileRuntimeTabContent({ startOnLaunchField !== undefined || showDiagnosticsIngress; const hasConfigurationRows = remainingConfigurationFields.length > 0; - const hasInstances = instances.length > 0; + const hasInstances = instances.length > 0 || archivedInstances.length > 0; if ( statusDiagnosticsFields.length === 0 && @@ -935,6 +878,7 @@ export function ProfileRuntimeTabContent({ {modelSettings} {hasInstances ? ( Date: Mon, 17 Aug 2026 18:49:03 +0100 Subject: [PATCH 07/16] Rename Bumble agent to Pollen (#5864) ## Summary - Rename the built-in Bumble agent to Pollen across desktop, onboarding, docs, and test fixtures. - Migrate existing stock definitions and instances in place while preserving customized fields and the stable persona coordinate. - Reserve the Pollen name by removing it from Fizz's generated-name pool. ## Validation - Pre-push desktop checks, typecheck, 4,791 frontend tests, Tauri clippy, and 2,432 native tests - Desktop E2E build --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Wes Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Co-authored-by: Wes Co-authored-by: Carl --- .../starter-team/{bumble.png => pollen.png} | Bin desktop/src-tauri/src/commands/agents.rs | 16 +- .../src-tauri/src/commands/agents_profile.rs | 113 ++- desktop/src-tauri/src/commands/workspace.rs | 11 + .../src-tauri/src/managed_agents/personas.rs | 24 +- .../src/managed_agents/personas/tests.rs | 2 +- .../src-tauri/src/managed_agents/restore.rs | 68 ++ desktop/src-tauri/src/migration.rs | 12 +- desktop/src-tauri/src/migration/pollen.rs | 862 ++++++++++++++++++ .../agents/lib/useBotRecents.test.mjs | 2 +- .../src/features/agents/lib/useBotRecents.ts | 2 +- .../onboarding/ui/CommunityOnboardingFlow.tsx | 4 +- .../onboarding/ui/WelcomeKickoffStage.tsx | 2 +- .../src/features/onboarding/welcomeCanvas.ts | 2 +- .../features/onboarding/welcomeGuide.test.mjs | 10 +- .../src/features/onboarding/welcomeGuide.ts | 8 +- .../onboarding/welcomeKickoff.test.mjs | 90 +- desktop/src/testing/e2eBridge.ts | 4 +- desktop/tests/e2e/agents.spec.ts | 4 +- desktop/tests/e2e/mentions.spec.ts | 30 +- desktop/tests/e2e/onboarding.spec.ts | 2 +- docs/welcome-kickoff-silent-failures.md | 14 +- 22 files changed, 1157 insertions(+), 125 deletions(-) rename desktop/public/onboarding/starter-team/{bumble.png => pollen.png} (100%) create mode 100644 desktop/src-tauri/src/migration/pollen.rs diff --git a/desktop/public/onboarding/starter-team/bumble.png b/desktop/public/onboarding/starter-team/pollen.png similarity index 100% rename from desktop/public/onboarding/starter-team/bumble.png rename to desktop/public/onboarding/starter-team/pollen.png diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3d38f37432c..c4fc0cf2f61 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1034,19 +1034,7 @@ pub async fn start_managed_agent( // profile reconcile (the create-time snapshot may be empty or stale for // a persona-inherited harness). let reconcile_personas = load_personas(&app).unwrap_or_default(); - let reconcile_effective_command = - crate::managed_agents::record_agent_command(record, &reconcile_personas); - - let reconcile = ProfileReconcileData { - private_key_nsec: record.private_key_nsec.clone(), - name: record.name.clone(), - relay_url: record.relay_url.clone(), - avatar_url: record.avatar_url.clone(), - auth_tag: record.auth_tag.clone(), - pubkey: record.pubkey.clone(), - agent_command: reconcile_effective_command, - persona_id: record.persona_id.clone(), - }; + let reconcile = profile_reconcile_data(record, &reconcile_personas); let target = if record.backend == BackendKind::Local { StartTarget::Local @@ -1297,9 +1285,9 @@ use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; #[path = "agents_profile.rs"] mod profile; +pub(crate) use profile::*; #[cfg(test)] use profile::{profile_needs_sync, resolve_legacy_avatar}; -pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData}; #[cfg(test)] #[path = "agents_tests.rs"] diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 0675d4c48f4..d28b58b50a7 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -2,17 +2,27 @@ //! guard). Owns the reconcile data carrier, the legacy-avatar backfill, and //! the needs-sync predicate. -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; use crate::app_state::AppState; use crate::managed_agents::managed_agent_avatar_url; use super::*; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProfileReconcileOutcome { + Reconciled, + SkippedDisabled, +} + pub(crate) struct ProfileReconcileData { pub(crate) private_key_nsec: String, pub(crate) name: String, pub(crate) relay_url: String, + /// Exact relay for migration work captured while a community is active. + /// Ordinary runtime reconciliation leaves this unset and resolves against + /// the current workspace at execution time. + pub(crate) target_relay_url: Option, /// Expected avatar URL for the published profile. `None` for legacy records /// that predate the `avatar_url` field — these will be backfilled from the /// relay's existing kind:0 profile on first reconciliation. @@ -49,6 +59,88 @@ pub(super) fn resolve_legacy_avatar( .unwrap_or_default() } +pub(crate) fn profile_reconcile_data( + record: &crate::managed_agents::ManagedAgentRecord, + personas: &[crate::managed_agents::AgentDefinition], +) -> ProfileReconcileData { + ProfileReconcileData { + private_key_nsec: record.private_key_nsec.clone(), + name: record.name.clone(), + relay_url: record.relay_url.clone(), + target_relay_url: None, + avatar_url: record.avatar_url.clone(), + auth_tag: record.auth_tag.clone(), + pubkey: record.pubkey.clone(), + agent_command: crate::managed_agents::record_agent_command(record, personas), + persona_id: record.persona_id.clone(), + } +} + +pub(crate) fn load_pending_profile_reconciliations( + app: &AppHandle, + workspace_relay: &str, +) -> Result, String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let store_path = crate::managed_agents::managed_agents_store_path(app)?; + let queue_path = crate::migration::profile_reconcile_queue_path(&store_path); + if !queue_path.exists() { + return Ok(Vec::new()); + } + + let relay_key = crate::migration::profile_reconcile_relay_key(workspace_relay)?; + let pending = crate::migration::read_profile_reconcile_queue(&queue_path)?; + let records = crate::managed_agents::load_managed_agents(app)?; + let personas = crate::managed_agents::load_personas(app).unwrap_or_default(); + Ok(records + .iter() + // A queue write deliberately precedes the migrated agent-store write. + // If the process dies between them, retain (but do not execute) the + // stale item until the next boot finishes renaming the record. + .filter(|record| { + pending.iter().any(|entry| { + entry.pubkey == record.pubkey + && entry.expected_name == record.name + && !entry + .reconciled_relays + .iter() + .any(|relay| relay == &relay_key) + }) + }) + .map(|record| { + let mut data = profile_reconcile_data(record, &personas); + // Pin the relay captured by the caller. Otherwise a fast community + // switch could make a queued task for A run on B. + data.target_relay_url = Some(workspace_relay.to_string()); + (record.pubkey.clone(), data) + }) + .collect()) +} + +pub(crate) fn mark_profile_reconciled( + app: &AppHandle, + pubkey: &str, + relay_url: &str, +) -> Result<(), String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let store_path = crate::managed_agents::managed_agents_store_path(app)?; + let queue_path = crate::migration::profile_reconcile_queue_path(&store_path); + if !queue_path.exists() { + return Ok(()); + } + let relay_key = crate::migration::profile_reconcile_relay_key(relay_url)?; + let mut pending = crate::migration::read_profile_reconcile_queue(&queue_path)?; + crate::migration::record_profile_reconciled(&mut pending, pubkey, relay_key); + crate::migration::write_profile_reconcile_queue(&queue_path, &pending) +} + /// Reconcile an agent's kind:0 profile on the relay. /// /// Queries the relay for the agent's existing profile and re-publishes if missing @@ -71,21 +163,21 @@ pub(crate) async fn reconcile_agent_profile( app: &AppHandle, agent_pubkey: &str, data: &ProfileReconcileData, -) -> Result<(), String> { +) -> Result { use crate::relay::{query_agent_profile, sync_managed_agent_profile}; // An explicit per-agent relay wins; an empty one falls back to the active // workspace relay. Resolved once and used for both the read and write-back. - let relay_url = crate::relay::effective_agent_relay_url( - &data.relay_url, - &relay_ws_url_with_override(state), - ); + let workspace_relay = relay_ws_url_with_override(state); + let relay_url = data.target_relay_url.clone().unwrap_or_else(|| { + crate::relay::effective_agent_relay_url(&data.relay_url, &workspace_relay) + }); if !state .managed_agent_profile_reconcile_enabled .load(std::sync::atomic::Ordering::Acquire) { - return Ok(()); + return Ok(ProfileReconcileOutcome::SkippedDisabled); } // Query the relay for the agent's existing kind:0 profile. @@ -137,7 +229,7 @@ pub(crate) async fn reconcile_agent_profile( }; if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) { - return Ok(()); + return Ok(ProfileReconcileOutcome::Reconciled); } let agent_keys = Keys::parse(&data.private_key_nsec) @@ -147,7 +239,7 @@ pub(crate) async fn reconcile_agent_profile( .managed_agent_profile_reconcile_enabled .load(std::sync::atomic::Ordering::Acquire) { - return Ok(()); + return Ok(ProfileReconcileOutcome::SkippedDisabled); } sync_managed_agent_profile( @@ -158,7 +250,8 @@ pub(crate) async fn reconcile_agent_profile( expected_avatar.as_deref(), data.auth_tag.as_deref(), ) - .await + .await?; + Ok(ProfileReconcileOutcome::Reconciled) } /// Decide whether a published profile is missing or stale relative to the diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39ac..a32fd2c0e0f 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -132,6 +132,9 @@ pub async fn apply_workspace( app: AppHandle, ) -> Result<(), String> { let restore_app = app.clone(); + // Capture the caller's relay before the blocking apply. Reading shared + // state afterward could pick up a newer concurrent community switch. + let profile_reconcile_relay = relay_url.clone(); tokio::task::spawn_blocking(move || { let state = app.state::(); @@ -213,6 +216,14 @@ pub async fn apply_workspace( let state = restore_app.state::(); super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; + // The Bumble→Pollen migration may have renamed stopped agents. Reconcile + // their relay profiles independently of runtime restore; successful writes + // record this relay while retaining the agent for other communities, and + // failures retry on the next workspace apply. + crate::managed_agents::spawn_pending_profile_reconciliations( + &restore_app, + &profile_reconcile_relay, + ); // Backfill this exact relay+owner scope only after the workspace has been // applied. Running at process boot would target the fallback relay and diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 9bf7ab74b01..8ff0e633dc8 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -23,7 +23,17 @@ const FIZZ_SYSTEM_PROMPT: &str = "You are Fizz, an energetic maker who turns ide const HONEY_SYSTEM_PROMPT: &str = "You are Honey, a warm and thoughtful communicator. Help users write clearly, organize ideas, brainstorm, summarize, and prepare for conversations. Be kind, creative, and concise. Add occasional bee wordplay or 🍯🐝—keep it sweet, never excessive."; -const BUMBLE_SYSTEM_PROMPT: &str = "You are Bumble, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic."; +// Keep the published NIP-33 coordinate stable so existing Pollen agents and +// references are upgraded in place instead of being orphaned by the rename. +pub(crate) const POLLEN_PERSONA_ID: &str = "builtin:bumble"; +pub(crate) const POLLEN_DISPLAY_NAME: &str = "Pollen"; +pub(crate) const POLLEN_SYSTEM_PROMPT: &str = "You are Pollen, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic."; +pub(crate) const POLLEN_LEGACY_DISPLAY_NAME: &str = "Bumble"; +pub(crate) const POLLEN_LEGACY_SYSTEM_PROMPT: &str = "You are Bumble, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic."; +// The embedded bytes are unchanged by the display-name migration. Keep the +// original storage symbol as the compatibility source and expose the current +// product name everywhere it is consumed. +const POLLEN_AVATAR: &str = BUMBLE_AVATAR; const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ BuiltInPersona { @@ -32,7 +42,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ avatar_url: Some(FIZZ_AVATAR), system_prompt: FIZZ_SYSTEM_PROMPT, name_pool: &[ - "Nectar", "Comet", "Bramble", "Clover", "Pollen", "Amber", "Daisy", "Mason", "Thistle", + "Nectar", "Comet", "Bramble", "Clover", "Amber", "Daisy", "Mason", "Thistle", "Waxwing", "Hive", "Meadow", "Juniper", "Aster", "Sage", "Willow", "Orchard", "Buzz", ], model: None, @@ -50,11 +60,11 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ default_active: true, }, BuiltInPersona { - id: "builtin:bumble", - display_name: "Bumble", - avatar_url: Some(BUMBLE_AVATAR), - system_prompt: BUMBLE_SYSTEM_PROMPT, - name_pool: &["Bumble"], + id: POLLEN_PERSONA_ID, + display_name: POLLEN_DISPLAY_NAME, + avatar_url: Some(POLLEN_AVATAR), + system_prompt: POLLEN_SYSTEM_PROMPT, + name_pool: &[POLLEN_DISPLAY_NAME], model: None, runtime: None, default_active: true, diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 387b4d72c65..cc21861a9f3 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -45,7 +45,7 @@ fn merge_personas_adds_missing_built_ins() { .iter() .map(|record| record.display_name.as_str()) .collect(); - assert_eq!(display_names, vec!["Fizz", "Honey", "Bumble"]); + assert_eq!(display_names, vec!["Fizz", "Honey", "Pollen"]); let active_ids: Vec<&str> = records .iter() .filter(|record| record.is_active) diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 25dadbeec60..895aad712a8 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -438,6 +438,7 @@ pub async fn restore_managed_agents_on_launch( private_key_nsec: record.private_key_nsec.clone(), name: record.name.clone(), relay_url: record.relay_url.clone(), + target_relay_url: None, avatar_url: record.avatar_url.clone(), auth_tag: record.auth_tag.clone(), pubkey: record.pubkey.clone(), @@ -472,6 +473,73 @@ pub async fn restore_managed_agents_on_launch( Ok(()) } +fn profile_reconcile_completed(outcome: crate::commands::ProfileReconcileOutcome) -> bool { + outcome == crate::commands::ProfileReconcileOutcome::Reconciled +} + +pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, workspace_relay: &str) { + let state = app.state::(); + if !state + .managed_agent_profile_reconcile_enabled + .load(Ordering::Acquire) + { + return; + } + let items = match crate::commands::load_pending_profile_reconciliations(app, workspace_relay) { + Ok(items) => items, + Err(error) => { + eprintln!("buzz-desktop: failed to load pending profile reconciliations: {error}"); + return; + } + }; + + for (pubkey, data) in items { + let reconcile_app = app.clone(); + let relay_url = data + .target_relay_url + .clone() + .unwrap_or_else(|| data.relay_url.clone()); + tauri::async_runtime::spawn(async move { + let state = reconcile_app.state::(); + match crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data) + .await + { + Ok(outcome) if profile_reconcile_completed(outcome) => { + if let Err(error) = crate::commands::mark_profile_reconciled( + &reconcile_app, + &pubkey, + &relay_url, + ) { + eprintln!( + "buzz-desktop: failed to record profile reconciliation for agent {pubkey}: {error}" + ); + } + } + Ok(_) => {} + Err(error) => eprintln!( + "buzz-desktop: profile reconciliation failed for agent {pubkey}: {error}" + ), + } + }); + } +} + +#[cfg(test)] +mod profile_reconcile_tests { + use super::profile_reconcile_completed; + use crate::commands::ProfileReconcileOutcome; + + #[test] + fn skipped_reconciliation_never_retires_pending_work() { + assert!(profile_reconcile_completed( + ProfileReconcileOutcome::Reconciled + )); + assert!(!profile_reconcile_completed( + ProfileReconcileOutcome::SkippedDisabled + )); + } +} + #[cfg(feature = "mesh-llm")] fn persist_restore_error( app: &tauri::AppHandle, diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index b3e613621ec..0baba6456b9 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -169,13 +169,11 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { } migrate_persona_provider_to_runtime(app); reconcile_legacy_command_names(app); - // Fold personas.json into the unified store HERE: after the JSON-level - // personas.json migrations above (which must see the legacy file), and - // before every consumer of the load/save_personas shims below — - // sync_team_personas would otherwise operate on an empty definition set. - // Post-fold readers of the runtime map (`load_persona_runtimes`) fall - // back to the unified store's definitions. + // Fold personas.json after its JSON-level migrations and before consumers + // below; otherwise sync_team_personas sees an empty definition set. + // Post-fold runtime reads fall back to unified-store definitions. fold_personas_into_agent_store(app); + pollen::migrate_pollen_agent_name(app); // Clean the legacy baked team-instructions suffix out of stored prompts // AFTER the fold (so definitions lifted out of personas.json are cleaned in // the same boot) and BEFORE backfill_standalone_agents (so a manufactured @@ -1376,6 +1374,8 @@ mod backfill; pub use backfill::backfill_standalone_agents; mod detach; pub use detach::detach_directory_backed_teams; +mod pollen; +pub(crate) use pollen::*; mod team_suffix; pub use team_suffix::strip_baked_team_instructions; diff --git a/desktop/src-tauri/src/migration/pollen.rs b/desktop/src-tauri/src/migration/pollen.rs new file mode 100644 index 00000000000..4276301ea23 --- /dev/null +++ b/desktop/src-tauri/src/migration/pollen.rs @@ -0,0 +1,862 @@ +//! Compatibility migration for the Bumble-to-Pollen built-in agent rename. + +use std::path::Path; + +use tauri::Manager; + +use super::persona_version_from_record; + +/// Rename the built-in research agent in persisted definitions and linked +/// instances without overwriting user-customized fields. +pub(super) fn migrate_pollen_agent_name(app: &tauri::AppHandle) { + let Ok(dir) = app.path().app_data_dir() else { + return; + }; + let path = dir.join("agents/managed-agents.json"); + if path.exists() { + migrate_pollen_agent_name_in_file(&path, &crate::util::now_iso()); + } +} + +fn migrate_pollen_agent_name_in_file(path: &Path, now: &str) { + let Ok(contents) = std::fs::read_to_string(path) else { + return; + }; + let Ok(mut records) = serde_json::from_str::>(&contents) else { + eprintln!( + "buzz-desktop: migrate-pollen-agent-name: invalid JSON in {}", + path.display() + ); + return; + }; + + let mut version_updates = stock_version_updates(now); + let has_stock_pollen_instance = records.iter().any(|record| { + record + .get("pubkey") + .and_then(serde_json::Value::as_str) + .is_some_and(|key| !key.is_empty()) + && record.get("persona_id").and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_PERSONA_ID) + && record.get("name").and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME) + }); + let mut occupied_names = records + .iter() + .filter_map(|record| record.get("name").and_then(serde_json::Value::as_str)) + .map(|name| name.to_lowercase()) + .collect::>(); + let mut profile_reconciliations = Vec::new(); + let mut changed = false; + + // Migrate the definition first so an in-sync linked instance can advance + // its source version instead of surfacing a false out-of-date warning. + for record in &mut records { + let is_definition = record + .get("pubkey") + .and_then(serde_json::Value::as_str) + .is_some_and(str::is_empty); + let Some(persona_id) = record + .get("slug") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + else { + continue; + }; + if !is_definition { + continue; + } + + let old_version = persona_version_from_record(record); + let Some(object) = record.as_object_mut() else { + continue; + }; + let record_changed = if persona_id == crate::managed_agents::POLLEN_PERSONA_ID { + migrate_pollen_fields(object, true) + } else if persona_id == "builtin:fizz" { + remove_pollen_from_legacy_fizz_name_pool(object) + } else { + false + }; + if !record_changed { + continue; + } + + object.insert( + "updated_at".to_string(), + serde_json::Value::String(now.to_string()), + ); + changed = true; + if let (Some(old_version), Some(new_version)) = + (old_version, persona_version_from_record(record)) + { + version_updates.insert(persona_id, (old_version, new_version)); + } + } + + for record in &mut records { + let is_instance = record + .get("pubkey") + .and_then(serde_json::Value::as_str) + .is_some_and(|pubkey| !pubkey.is_empty()); + let Some(persona_id) = record + .get("persona_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + else { + continue; + }; + let is_pollen_instance = persona_id == crate::managed_agents::POLLEN_PERSONA_ID; + let is_legacy_fizz_pollen = has_stock_pollen_instance + && persona_id == "builtin:fizz" + && record.get("name").and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_DISPLAY_NAME); + // Definition rows are absent on direct upgrades from the pre-unified + // persona store. The stock hashes still let pristine linked instances + // advance instead of appearing falsely out of date after seeding. + let version_update = version_updates.get(&persona_id); + if !is_instance || (!is_pollen_instance && version_update.is_none()) { + continue; + } + + let source_was_current = version_update.is_some_and(|(old, _)| { + record + .get("persona_source_version") + .and_then(serde_json::Value::as_str) + == Some(old.as_str()) + }); + let Some(object) = record.as_object_mut() else { + continue; + }; + let name_was_migrated = is_pollen_instance + && object.get("name").and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME); + let mut record_changed = is_pollen_instance && migrate_pollen_fields(object, false); + if is_legacy_fizz_pollen && source_was_current { + let replacement = unique_legacy_fizz_name(&occupied_names); + occupied_names.insert(replacement.to_lowercase()); + object.insert( + "name".to_string(), + serde_json::Value::String(replacement.clone()), + ); + if let Some(pubkey) = object + .get("pubkey") + .and_then(serde_json::Value::as_str) + .filter(|pubkey| !pubkey.is_empty()) + { + profile_reconciliations.push((pubkey.to_string(), replacement)); + } + record_changed = true; + } + if name_was_migrated { + if let Some(pubkey) = object + .get("pubkey") + .and_then(serde_json::Value::as_str) + .filter(|pubkey| !pubkey.is_empty()) + { + profile_reconciliations.push(( + pubkey.to_string(), + crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + )); + } + } + if source_was_current { + if let Some((_, new_version)) = version_update { + object.insert( + "persona_source_version".to_string(), + serde_json::Value::String(new_version.clone()), + ); + record_changed = true; + } + } + if record_changed { + object.insert( + "updated_at".to_string(), + serde_json::Value::String(now.to_string()), + ); + changed = true; + } + } + + if !profile_reconciliations.is_empty() { + // Queue first: a crash after this write but before the agent-store write + // leaves harmless stale items. The loader verifies each queued expected + // name against the durable record before publishing. + if let Err(error) = persist_profile_reconcile_queue(path, &profile_reconciliations) { + eprintln!("buzz-desktop: migrate-pollen-agent-name: {error}"); + return; + } + if let Ok(bytes) = serde_json::to_vec_pretty(&records) { + if let Err(error) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) { + eprintln!("buzz-desktop: migrate-pollen-agent-name: {error}"); + } + } + } else if changed { + if let Ok(bytes) = serde_json::to_vec_pretty(&records) { + if let Err(error) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) { + eprintln!("buzz-desktop: migrate-pollen-agent-name: {error}"); + } + } + } +} + +fn unique_legacy_fizz_name(occupied_names: &std::collections::HashSet) -> String { + let base = "Pollen-Fizz"; + if !occupied_names.contains(&base.to_lowercase()) { + return base.to_string(); + } + for suffix in 2.. { + let candidate = format!("{base}-{suffix}"); + if !occupied_names.contains(&candidate.to_lowercase()) { + return candidate; + } + } + unreachable!() +} + +fn stock_version_updates(now: &str) -> std::collections::HashMap { + let mut updates = std::collections::HashMap::new(); + + if let Some(mut legacy_pollen) = crate::managed_agents::built_in_persona_definition( + crate::managed_agents::POLLEN_PERSONA_ID, + now, + ) { + let current_pollen = persona_version(&legacy_pollen); + legacy_pollen.display_name = crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME.to_string(); + legacy_pollen.system_prompt = + crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT.to_string(); + legacy_pollen.name_pool = + vec![crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME.to_string()]; + updates.insert( + crate::managed_agents::POLLEN_PERSONA_ID.to_string(), + (persona_version(&legacy_pollen), current_pollen), + ); + } + + if let Some(mut legacy_fizz) = + crate::managed_agents::built_in_persona_definition("builtin:fizz", now) + { + let current_fizz = persona_version(&legacy_fizz); + legacy_fizz + .name_pool + .insert(4, crate::managed_agents::POLLEN_DISPLAY_NAME.to_string()); + updates.insert( + "builtin:fizz".to_string(), + (persona_version(&legacy_fizz), current_fizz), + ); + } + + updates +} + +fn persona_version(definition: &crate::managed_agents::AgentDefinition) -> String { + crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(definition), + ) +} + +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +pub(crate) struct ProfileReconcileQueueEntry { + pub(crate) pubkey: String, + #[serde(default = "default_profile_reconcile_name")] + pub(crate) expected_name: String, + /// Canonical relay identities already repaired for this migrated agent. + /// + /// Keep the entry after success: Desktop does not persist its community + /// list in Rust, so a community that is inactive (or re-added later) must + /// still get one repair when it is next applied. + #[serde(default)] + pub(crate) reconciled_relays: Vec, +} + +fn default_profile_reconcile_name() -> String { + crate::managed_agents::POLLEN_DISPLAY_NAME.to_string() +} + +#[derive(serde::Deserialize)] +struct CurrentProfileReconcileQueueEntry { + pubkey: String, + #[serde(default = "default_profile_reconcile_name")] + expected_name: String, + #[serde(default)] + reconciled_relays: Vec, +} + +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum StoredProfileReconcileQueueEntry { + Current(CurrentProfileReconcileQueueEntry), + Legacy(String), +} + +impl<'de> serde::Deserialize<'de> for ProfileReconcileQueueEntry { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match StoredProfileReconcileQueueEntry::deserialize(deserializer)? { + StoredProfileReconcileQueueEntry::Current(entry) => Ok(Self { + pubkey: entry.pubkey, + expected_name: entry.expected_name, + reconciled_relays: entry.reconciled_relays, + }), + StoredProfileReconcileQueueEntry::Legacy(pubkey) => Ok(Self { + pubkey, + expected_name: default_profile_reconcile_name(), + reconciled_relays: Vec::new(), + }), + } + } +} + +pub(crate) fn profile_reconcile_queue_path(agent_store_path: &Path) -> std::path::PathBuf { + agent_store_path.with_file_name("profile-reconcile-pending.json") +} + +fn persist_profile_reconcile_queue( + path: &Path, + reconciliations: &[(String, String)], +) -> Result<(), String> { + let queue_path = profile_reconcile_queue_path(path); + let mut pending = if queue_path.exists() { + read_profile_reconcile_queue(&queue_path).unwrap_or_default() + } else { + Vec::new() + }; + for (pubkey, expected_name) in reconciliations { + if let Some(entry) = pending.iter_mut().find(|entry| entry.pubkey == *pubkey) { + entry.expected_name.clone_from(expected_name); + entry.reconciled_relays.clear(); + } else { + pending.push(ProfileReconcileQueueEntry { + pubkey: pubkey.clone(), + expected_name: expected_name.clone(), + reconciled_relays: Vec::new(), + }); + } + } + pending.sort_by(|left, right| left.pubkey.cmp(&right.pubkey)); + write_profile_reconcile_queue(&queue_path, &pending) +} + +pub(crate) const PROFILE_RECONCILE_QUEUE_MAX_BYTES: usize = 1024 * 1024; + +pub(crate) fn read_profile_reconcile_queue( + path: &Path, +) -> Result, String> { + let metadata = std::fs::metadata(path) + .map_err(|error| format!("failed to inspect profile reconcile queue: {error}"))?; + if metadata.len() > PROFILE_RECONCILE_QUEUE_MAX_BYTES as u64 { + return Err("profile reconcile queue exceeds its size limit".to_string()); + } + let contents = std::fs::read_to_string(path) + .map_err(|error| format!("failed to read profile reconcile queue: {error}"))?; + serde_json::from_str(&contents) + .map_err(|error| format!("failed to parse profile reconcile queue: {error}")) +} + +pub(crate) fn write_profile_reconcile_queue( + path: &Path, + entries: &[ProfileReconcileQueueEntry], +) -> Result<(), String> { + if entries.is_empty() { + return match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove empty profile reconcile queue {}: {error}", + path.display() + )), + }; + } + let bytes = serde_json::to_vec_pretty(entries) + .map_err(|error| format!("failed to serialize profile reconcile queue: {error}"))?; + if bytes.len() > PROFILE_RECONCILE_QUEUE_MAX_BYTES { + return Err("profile reconcile queue exceeds its size limit".to_string()); + } + crate::managed_agents::atomic_write_json_restricted(path, &bytes) +} + +pub(crate) fn profile_reconcile_relay_key(relay_url: &str) -> Result { + buzz_core_pkg::relay::normalize_relay_url(relay_url) + .map_err(|error| format!("invalid profile reconcile relay: {error}")) +} + +#[cfg(test)] +pub(crate) fn profile_reconcile_is_pending( + entries: &[ProfileReconcileQueueEntry], + pubkey: &str, + relay_key: &str, +) -> bool { + entries.iter().any(|entry| { + entry.pubkey == pubkey + && !entry + .reconciled_relays + .iter() + .any(|relay| relay == relay_key) + }) +} + +pub(crate) fn record_profile_reconciled( + entries: &mut [ProfileReconcileQueueEntry], + pubkey: &str, + relay_key: String, +) { + if let Some(entry) = entries.iter_mut().find(|entry| entry.pubkey == pubkey) { + if !entry.reconciled_relays.contains(&relay_key) { + entry.reconciled_relays.push(relay_key); + entry.reconciled_relays.sort(); + } + } +} + +fn migrate_pollen_fields( + record: &mut serde_json::Map, + is_definition: bool, +) -> bool { + let mut changed = false; + for key in ["name", "display_name"] { + if record.get(key).and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME) + { + record.insert( + key.to_string(), + serde_json::Value::String(crate::managed_agents::POLLEN_DISPLAY_NAME.to_string()), + ); + changed = true; + } + } + if record + .get("system_prompt") + .and_then(serde_json::Value::as_str) + == Some(crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT) + { + record.insert( + "system_prompt".to_string(), + serde_json::Value::String(crate::managed_agents::POLLEN_SYSTEM_PROMPT.to_string()), + ); + changed = true; + } + if is_definition + && record + .get("name_pool") + .and_then(serde_json::Value::as_array) + .is_some_and(|names| { + names.len() == 1 + && names[0].as_str() == Some(crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME) + }) + { + record.insert( + "name_pool".to_string(), + serde_json::json!([crate::managed_agents::POLLEN_DISPLAY_NAME]), + ); + changed = true; + } + changed +} + +fn remove_pollen_from_legacy_fizz_name_pool( + record: &mut serde_json::Map, +) -> bool { + const LEGACY_FIZZ_NAME_POOL: &[&str] = &[ + "Nectar", "Comet", "Bramble", "Clover", "Pollen", "Amber", "Daisy", "Mason", "Thistle", + "Waxwing", "Hive", "Meadow", "Juniper", "Aster", "Sage", "Willow", "Orchard", "Buzz", + ]; + let Some(names) = record + .get("name_pool") + .and_then(serde_json::Value::as_array) + else { + return false; + }; + if !names + .iter() + .map(|name| name.as_str()) + .eq(LEGACY_FIZZ_NAME_POOL.iter().copied().map(Some)) + { + return false; + } + + let names_without_pollen = names + .iter() + .filter(|name| name.as_str() != Some(crate::managed_agents::POLLEN_DISPLAY_NAME)) + .cloned() + .collect(); + record.insert( + "name_pool".to_string(), + serde_json::Value::Array(names_without_pollen), + ); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::migration::test_support::{read_agents_json, write_agents_json}; + + #[test] + fn pollen_name_migration_updates_seeded_fields_and_preserves_customizations() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agents/managed-agents.json"); + let mut legacy_definition = crate::managed_agents::built_in_persona_definition( + crate::managed_agents::POLLEN_PERSONA_ID, + "before", + ) + .unwrap(); + legacy_definition.display_name = + crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME.to_string(); + legacy_definition.system_prompt = + crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT.to_string(); + legacy_definition.name_pool = + vec![crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME.to_string()]; + let old_version = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(&legacy_definition), + ); + let mut current_definition = legacy_definition.clone(); + current_definition.display_name = crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(); + current_definition.system_prompt = crate::managed_agents::POLLEN_SYSTEM_PROMPT.to_string(); + current_definition.name_pool = vec![crate::managed_agents::POLLEN_DISPLAY_NAME.to_string()]; + let new_version = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(¤t_definition), + ); + + let mut definition_record = + serde_json::to_value(legacy_definition.into_agent_record()).unwrap(); + definition_record["future_definition_field"] = serde_json::json!("preserved"); + let pristine_instance = serde_json::json!({ + "pubkey": "pristine-pubkey", + "name": crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME, + "persona_id": crate::managed_agents::POLLEN_PERSONA_ID, + "system_prompt": crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT, + "persona_source_version": old_version, + "start_on_app_launch": false, + "updated_at": "before", + "future_instance_field": "preserved" + }); + let customized_instance = serde_json::json!({ + "pubkey": "customized-pubkey", + "name": "My researcher", + "persona_id": crate::managed_agents::POLLEN_PERSONA_ID, + "system_prompt": "User-edited instructions", + "persona_source_version": "custom-version", + "updated_at": "before" + }); + let unrelated = serde_json::json!({ + "pubkey": "honey-pubkey", + "name": "Honey", + "persona_id": "builtin:honey", + "system_prompt": "You are Honey.", + "updated_at": "before" + }); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition_record, + pristine_instance, + customized_instance, + unrelated + ]), + ); + + migrate_pollen_agent_name_in_file(&path, "after"); + + let records = read_agents_json(dir.path()); + assert_eq!( + records[0]["slug"], + crate::managed_agents::POLLEN_PERSONA_ID, + "the persisted compatibility id must remain stable" + ); + assert_eq!( + records[0]["name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!( + records[0]["display_name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!( + records[0]["system_prompt"], + crate::managed_agents::POLLEN_SYSTEM_PROMPT + ); + assert_eq!( + records[0]["name_pool"], + serde_json::json!([crate::managed_agents::POLLEN_DISPLAY_NAME]) + ); + assert_eq!(records[0]["future_definition_field"], "preserved"); + assert_eq!(records[0]["updated_at"], "after"); + + assert_eq!( + records[1]["name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!( + records[1]["system_prompt"], + crate::managed_agents::POLLEN_SYSTEM_PROMPT + ); + assert_eq!(records[1]["persona_source_version"], new_version); + assert_eq!(records[1]["future_instance_field"], "preserved"); + assert_eq!(records[1]["updated_at"], "after"); + + assert_eq!(records[2]["name"], "My researcher"); + assert_eq!(records[2]["system_prompt"], "User-edited instructions"); + assert_eq!(records[2]["persona_source_version"], "custom-version"); + assert_eq!(records[2]["updated_at"], "before"); + assert_eq!(records[3], unrelated); + assert_eq!( + read_profile_reconcile_queue(&profile_reconcile_queue_path(&path)).unwrap(), + vec![ProfileReconcileQueueEntry { + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + pubkey: "pristine-pubkey".to_string(), + reconciled_relays: Vec::new(), + }], + "a stopped stock instance must retry its relay profile independently of startup" + ); + + let once = std::fs::read(&path).unwrap(); + migrate_pollen_agent_name_in_file(&path, "later"); + assert_eq!( + std::fs::read(path).unwrap(), + once, + "migration is idempotent" + ); + } + + #[test] + fn pollen_name_migration_advances_stock_versions_without_definition_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agents/managed-agents.json"); + let updates = stock_version_updates("before"); + let (old_pollen, new_pollen) = updates + .get(crate::managed_agents::POLLEN_PERSONA_ID) + .unwrap(); + let (old_fizz, new_fizz) = updates.get("builtin:fizz").unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([ + { + "pubkey": "pollen-pubkey", + "name": crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME, + "persona_id": crate::managed_agents::POLLEN_PERSONA_ID, + "system_prompt": crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT, + "persona_source_version": old_pollen, + "start_on_app_launch": false, + "updated_at": "before" + }, + { + "pubkey": "fizz-pubkey", + "name": "Fizz", + "persona_id": "builtin:fizz", + "persona_source_version": old_fizz, + "updated_at": "before" + } + ]), + ); + + migrate_pollen_agent_name_in_file(&path, "after"); + + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["persona_source_version"], *new_pollen); + assert_eq!(records[1]["persona_source_version"], *new_fizz); + assert_eq!( + read_profile_reconcile_queue(&profile_reconcile_queue_path(&path)).unwrap(), + vec![ProfileReconcileQueueEntry { + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + pubkey: "pollen-pubkey".to_string(), + reconciled_relays: Vec::new(), + }] + ); + } + + #[test] + fn legacy_profile_reconcile_queue_remains_readable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("profile-reconcile-pending.json"); + std::fs::write(&path, r#"["pollen-pubkey"]"#).unwrap(); + + assert_eq!( + read_profile_reconcile_queue(&path).unwrap(), + vec![ProfileReconcileQueueEntry { + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + pubkey: "pollen-pubkey".to_string(), + reconciled_relays: Vec::new(), + }] + ); + } + + #[test] + fn profile_reconcile_queue_tracks_each_relay_without_dropping_other_communities() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("profile-reconcile-pending.json"); + let relay_a = profile_reconcile_relay_key("WSS://A.EXAMPLE:443/").unwrap(); + let relay_b = profile_reconcile_relay_key("wss://b.example").unwrap(); + let mut entries = vec![ProfileReconcileQueueEntry { + pubkey: "pollen-pubkey".to_string(), + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + reconciled_relays: Vec::new(), + }]; + assert!(profile_reconcile_is_pending( + &entries, + "pollen-pubkey", + &relay_a + )); + record_profile_reconciled(&mut entries, "pollen-pubkey", relay_a.clone()); + assert!(!profile_reconcile_is_pending( + &entries, + "pollen-pubkey", + &relay_a + )); + assert!(profile_reconcile_is_pending( + &entries, + "pollen-pubkey", + &relay_b + )); + + write_profile_reconcile_queue(&path, &entries).unwrap(); + assert_eq!(read_profile_reconcile_queue(&path).unwrap(), entries); + assert_eq!( + profile_reconcile_relay_key("wss://a.example").unwrap(), + profile_reconcile_relay_key("WSS://A.EXAMPLE:443/").unwrap(), + "equivalent relay spellings must share one completion key" + ); + } + + #[test] + fn empty_profile_reconcile_queue_is_removed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("profile-reconcile-pending.json"); + write_profile_reconcile_queue( + &path, + &[ProfileReconcileQueueEntry { + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + pubkey: "pollen-pubkey".to_string(), + reconciled_relays: Vec::new(), + }], + ) + .unwrap(); + assert!(path.exists()); + + write_profile_reconcile_queue(&path, &[]).unwrap(); + + assert!(!path.exists()); + } + + #[test] + fn pollen_name_migration_repairs_stock_fizz_collision_and_profiles() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agents/managed-agents.json"); + let updates = stock_version_updates("before"); + let old_pollen = &updates[crate::managed_agents::POLLEN_PERSONA_ID].0; + let old_fizz = &updates["builtin:fizz"].0; + write_agents_json( + dir.path(), + &serde_json::json!([ + { + "pubkey": "pollen-pubkey", + "name": crate::managed_agents::POLLEN_LEGACY_DISPLAY_NAME, + "persona_id": crate::managed_agents::POLLEN_PERSONA_ID, + "system_prompt": crate::managed_agents::POLLEN_LEGACY_SYSTEM_PROMPT, + "persona_source_version": old_pollen, + "updated_at": "before" + }, + { + "pubkey": "fizz-pubkey", + "name": crate::managed_agents::POLLEN_DISPLAY_NAME, + "persona_id": "builtin:fizz", + "persona_source_version": old_fizz, + "updated_at": "before" + }, + { + "pubkey": "occupied-pubkey", + "name": "pollen-fizz", + "persona_id": "custom:persona", + "updated_at": "before" + }, + { + "pubkey": "custom-fizz-pubkey", + "name": crate::managed_agents::POLLEN_DISPLAY_NAME, + "persona_id": "builtin:fizz", + "persona_source_version": "custom-version", + "updated_at": "before" + } + ]), + ); + + migrate_pollen_agent_name_in_file(&path, "after"); + + let records = read_agents_json(dir.path()); + assert_eq!( + records[0]["name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!(records[1]["name"], "Pollen-Fizz-2"); + assert_eq!(records[2]["name"], "pollen-fizz"); + assert_eq!( + records[3]["name"], + crate::managed_agents::POLLEN_DISPLAY_NAME + ); + assert_eq!(records[3]["updated_at"], "before"); + assert_eq!( + read_profile_reconcile_queue(&profile_reconcile_queue_path(&path)).unwrap(), + vec![ + ProfileReconcileQueueEntry { + pubkey: "fizz-pubkey".to_string(), + expected_name: "Pollen-Fizz-2".to_string(), + reconciled_relays: Vec::new(), + }, + ProfileReconcileQueueEntry { + pubkey: "pollen-pubkey".to_string(), + expected_name: crate::managed_agents::POLLEN_DISPLAY_NAME.to_string(), + reconciled_relays: Vec::new(), + }, + ] + ); + + let once = std::fs::read(&path).unwrap(); + migrate_pollen_agent_name_in_file(&path, "later"); + assert_eq!(std::fs::read(path).unwrap(), once); + } + + #[test] + fn pollen_name_migration_removes_the_new_name_from_the_legacy_fizz_pool() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agents/managed-agents.json"); + let mut legacy_fizz = + crate::managed_agents::built_in_persona_definition("builtin:fizz", "before").unwrap(); + legacy_fizz + .name_pool + .insert(4, crate::managed_agents::POLLEN_DISPLAY_NAME.to_string()); + let old_version = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(&legacy_fizz), + ); + let mut current_fizz = legacy_fizz.clone(); + current_fizz + .name_pool + .retain(|name| name != crate::managed_agents::POLLEN_DISPLAY_NAME); + let new_version = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(¤t_fizz), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + serde_json::to_value(legacy_fizz.into_agent_record()).unwrap(), + { + "pubkey": "fizz-pubkey", + "name": "Fizz", + "persona_id": "builtin:fizz", + "persona_source_version": old_version, + "updated_at": "before" + } + ]), + ); + + migrate_pollen_agent_name_in_file(&path, "after"); + + let records = read_agents_json(dir.path()); + assert_eq!( + records[0]["name_pool"], + serde_json::json!(current_fizz.name_pool) + ); + assert_eq!(records[0]["updated_at"], "after"); + assert_eq!(records[1]["persona_source_version"], new_version); + assert_eq!(records[1]["updated_at"], "after"); + } +} diff --git a/desktop/src/features/agents/lib/useBotRecents.test.mjs b/desktop/src/features/agents/lib/useBotRecents.test.mjs index 7bd8f8f6d2b..930d3211d4b 100644 --- a/desktop/src/features/agents/lib/useBotRecents.test.mjs +++ b/desktop/src/features/agents/lib/useBotRecents.test.mjs @@ -34,7 +34,7 @@ test("pickQuickBotPersonas prefers recents before defaults", () => { test("pickQuickBotPersonas seeds the three starter agents", () => { const personas = [ - createPersona("builtin:bumble", "Bumble"), + createPersona("builtin:bumble", "Pollen"), createPersona("builtin:honey", "Honey"), createPersona("builtin:fizz", "Fizz"), createPersona("builtin:reviewer", "Reviewer"), diff --git a/desktop/src/features/agents/lib/useBotRecents.ts b/desktop/src/features/agents/lib/useBotRecents.ts index cf54c00b2e0..193fa02856a 100644 --- a/desktop/src/features/agents/lib/useBotRecents.ts +++ b/desktop/src/features/agents/lib/useBotRecents.ts @@ -7,7 +7,7 @@ const MAX_RECENTS = 8; // Default persona display names to seed the list when empty. // These are resolved to IDs by the consumer. -export const DEFAULT_PERSONA_NAMES = ["Fizz", "Honey", "Bumble"] as const; +export const DEFAULT_PERSONA_NAMES = ["Fizz", "Honey", "Pollen"] as const; export function pickQuickBotPersonas( personas: readonly AgentPersona[], diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index d2af0cc3bdc..7897e51ffda 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -56,7 +56,7 @@ function isRelayMembershipDeniedError(error: unknown): boolean { const STARTER_PERSONA_ANIMATIONS: Record = { Fizz: "/onboarding/starter-team/fizz.png", Honey: "/onboarding/starter-team/honey.png", - Bumble: "/onboarding/starter-team/bumble.png", + Pollen: "/onboarding/starter-team/pollen.png", }; /** Fade duration for the "entering" curtain over the mounting app. */ @@ -205,7 +205,7 @@ export function CommunityOnboardingFlow({ void listPersonas() .then((personas) => setStarterPersonas( - ["Fizz", "Honey", "Bumble"].flatMap((name) => { + ["Fizz", "Honey", "Pollen"].flatMap((name) => { const persona = personas.find( (candidate) => candidate.displayName === name, ); diff --git a/desktop/src/features/onboarding/ui/WelcomeKickoffStage.tsx b/desktop/src/features/onboarding/ui/WelcomeKickoffStage.tsx index 40337f2a345..32649ad2bd8 100644 --- a/desktop/src/features/onboarding/ui/WelcomeKickoffStage.tsx +++ b/desktop/src/features/onboarding/ui/WelcomeKickoffStage.tsx @@ -15,7 +15,7 @@ type StageCharacter = { const STAGE_CHARACTERS: readonly StageCharacter[] = [ { name: "Fizz", animationUrl: "/onboarding/starter-team/fizz.png" }, { name: "Honey", animationUrl: "/onboarding/starter-team/honey.png" }, - { name: "Bumble", animationUrl: "/onboarding/starter-team/bumble.png" }, + { name: "Pollen", animationUrl: "/onboarding/starter-team/pollen.png" }, ]; const STAGE_EXIT_ANIMATION = "motion-kickoff-stage-exit"; diff --git a/desktop/src/features/onboarding/welcomeCanvas.ts b/desktop/src/features/onboarding/welcomeCanvas.ts index 7d592fe30a3..bdd8ff6371c 100644 --- a/desktop/src/features/onboarding/welcomeCanvas.ts +++ b/desktop/src/features/onboarding/welcomeCanvas.ts @@ -2,7 +2,7 @@ import { getCanvas, setCanvas } from "@/shared/api/tauri"; export const WELCOME_CANVAS_CONTENT = `# Welcome to Buzz -This private channel is your home base for getting oriented. Fizz, Honey, and Bumble can help you learn the app, troubleshoot setup, and work through something you are building. +This private channel is your home base for getting oriented. Fizz, Honey, and Pollen can help you learn the app, troubleshoot setup, and work through something you are building. ## Work with your agents diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index 59af9c75d3f..746a9487773 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -296,7 +296,7 @@ test("welcome team starter definitions and role identities are stable", () => { assert.deepEqual(WELCOME_TEAM_STARTERS, [ { name: "Fizz", personaId: "builtin:fizz", role: "lead" }, { name: "Honey", personaId: "builtin:honey", role: "teammate" }, - { name: "Bumble", personaId: "builtin:bumble", role: "teammate" }, + { name: "Pollen", personaId: "builtin:bumble", role: "teammate" }, ]); }); @@ -332,14 +332,14 @@ test("starter matching uses persona identity rather than display name", () => { }); test("starter matching is relay scoped and normalizes trailing slashes", () => { - const bumble = WELCOME_TEAM_STARTERS[2]; + const pollen = WELCOME_TEAM_STARTERS[2]; const otherRelay = makeAgent({ - personaId: bumble.personaId, + personaId: pollen.personaId, relayUrl: RELAY_B, status: "running", }); const matchingRelay = makeAgent({ - personaId: bumble.personaId, + personaId: pollen.personaId, relayUrl: `${RELAY_A}/`, pubkey: PUB_B, }); @@ -347,7 +347,7 @@ test("starter matching is relay scoped and normalizes trailing slashes", () => { assert.equal( pickWelcomeTeamStarterAgentForRelay( [otherRelay, matchingRelay], - bumble, + pollen, RELAY_A, ), matchingRelay, diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index 82b6886d742..29d966fa940 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -44,7 +44,7 @@ export type WelcomeTeamStarterDefinition = Readonly<{ export const WELCOME_TEAM_STARTERS = [ { name: "Fizz", personaId: "builtin:fizz", role: "lead" }, { name: "Honey", personaId: "builtin:honey", role: "teammate" }, - { name: "Bumble", personaId: "builtin:bumble", role: "teammate" }, + { name: "Pollen", personaId: "builtin:bumble", role: "teammate" }, ] as const satisfies readonly WelcomeTeamStarterDefinition[]; export type WelcomeTeamAgents = [ManagedAgent, ManagedAgent, ManagedAgent]; @@ -370,11 +370,11 @@ async function provisionWelcomeTeam( const created = await createManagedAgent(desired); agents.push(created.agent); } - const [lead, honey, bumble] = agents; - if (!lead || !honey || !bumble) { + const [lead, honey, pollen] = agents; + if (!lead || !honey || !pollen) { throw new Error("Welcome Team provisioning did not return every starter."); } - const welcomeAgents: WelcomeTeamAgents = [lead, honey, bumble]; + const welcomeAgents: WelcomeTeamAgents = [lead, honey, pollen]; const leadPubkey = lead.pubkey; for (const index of [1, 2] as const) { const teammate = welcomeAgents[index]; diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs index 736721434a1..d0834e44252 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.test.mjs +++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs @@ -31,12 +31,12 @@ function agent(name, personaId, pubkey) { const fizz = agent("Fizz", "builtin:fizz", "f".repeat(64)); const honey = agent("Honey", "builtin:honey", "h".repeat(64)); -const bumble = agent("Bumble", "builtin:bumble", "b".repeat(64)); +const pollen = agent("Pollen", "builtin:bumble", "b".repeat(64)); test("resolveWelcomeAgentSet orders agents by stable persona identity", () => { - assert.deepEqual(resolveWelcomeAgentSet([bumble, fizz, honey]), { + assert.deepEqual(resolveWelcomeAgentSet([pollen, fizz, honey]), { lead: fizz, - teammates: [honey, bumble], + teammates: [honey, pollen], }); assert.equal(resolveWelcomeAgentSet([fizz, honey]), null); }); @@ -44,29 +44,29 @@ test("resolveWelcomeAgentSet orders agents by stable persona identity", () => { test("opener uses current agent names and requests bounded simultaneous intros", () => { const opener = buildWelcomeKickoffOpener({ ...fizz, name: "Fizzy" }, [ { ...honey, name: "Honeybee" }, - bumble, + pollen, ]); assert.match(opener, /I'm Fizzy/); - assert.match(opener, /@Honeybee and @Bumble/); + assert.match(opener, /@Honeybee and @Pollen/); assert.doesNotMatch(opener, /@@/); assert.match(opener, /sentence or two/); assert.match(opener, /Don't start any work yet/); }); test("teammates are not ready until every harness publishes online presence", () => { - assert.equal(areWelcomeTeammatesOnline([honey, bumble], undefined), false); + assert.equal(areWelcomeTeammatesOnline([honey, pollen], undefined), false); assert.equal( - areWelcomeTeammatesOnline([honey, bumble], { + areWelcomeTeammatesOnline([honey, pollen], { [honey.pubkey]: "online", - [bumble.pubkey]: "offline", + [pollen.pubkey]: "offline", }), false, ); assert.equal( - areWelcomeTeammatesOnline([honey, bumble], { + areWelcomeTeammatesOnline([honey, pollen], { [honey.pubkey]: "online", - [bumble.pubkey]: "online", + [pollen.pubkey]: "online", }), true, ); @@ -74,41 +74,41 @@ test("teammates are not ready until every harness publishes online presence", () test("readiness wait observes agents becoming online without navigation", async () => { let reads = 0; - const ready = await waitForWelcomeTeammatesOnline([honey, bumble], { + const ready = await waitForWelcomeTeammatesOnline([honey, pollen], { isCancelled: () => false, loadPresence: async () => { reads += 1; return reads < 3 - ? { [honey.pubkey]: "online", [bumble.pubkey]: "offline" } - : { [honey.pubkey]: "online", [bumble.pubkey]: "online" }; + ? { [honey.pubkey]: "online", [pollen.pubkey]: "offline" } + : { [honey.pubkey]: "online", [pollen.pubkey]: "online" }; }, pollMs: 0, waitMs: 1_000, }); - assert.deepEqual(ready, [honey, bumble]); + assert.deepEqual(ready, [honey, pollen]); assert.equal(reads, 3); }); test("readiness wait retries transient presence failures", async () => { let reads = 0; - const ready = await waitForWelcomeTeammatesOnline([honey, bumble], { + const ready = await waitForWelcomeTeammatesOnline([honey, pollen], { isCancelled: () => false, loadPresence: async () => { reads += 1; if (reads === 1) throw new Error("relay unavailable"); - return { [honey.pubkey]: "online", [bumble.pubkey]: "online" }; + return { [honey.pubkey]: "online", [pollen.pubkey]: "online" }; }, pollMs: 0, waitMs: 1_000, }); - assert.deepEqual(ready, [honey, bumble]); + assert.deepEqual(ready, [honey, pollen]); assert.equal(reads, 2); }); test("readiness wait cancels when Welcome loses focus", async () => { - const ready = await waitForWelcomeTeammatesOnline([honey, bumble], { + const ready = await waitForWelcomeTeammatesOnline([honey, pollen], { isCancelled: () => true, loadPresence: async () => { throw new Error("cancelled waits must not query"); @@ -152,23 +152,23 @@ test("closer degrades coherently for partial and total startup failure", () => { assert.match(buildWelcomeKickoffCloser([]), /What can we help you build/); assert.match(buildWelcomeKickoffCloser(["Honey"]), /Honey is having trouble/); assert.match( - buildWelcomeKickoffCloser(["Honey", "Bumble"]), - /Honey and Bumble couldn't start/, + buildWelcomeKickoffCloser(["Honey", "Pollen"]), + /Honey and Pollen couldn't start/, ); assert.match( - buildWelcomeKickoffCloser(["Honey", "Bumble"]), + buildWelcomeKickoffCloser(["Honey", "Pollen"]), /I'm still here to help/, ); }); test("closer names teammates that did not reply before the intro wait", () => { assert.match( - buildWelcomeKickoffCloser([], ["Bumble"]), - /Bumble is taking longer to reply/, + buildWelcomeKickoffCloser([], ["Pollen"]), + /Pollen is taking longer to reply/, ); assert.match( - buildWelcomeKickoffCloser(["Honey"], ["Bumble"]), - /Honey and Bumble are taking longer than expected/, + buildWelcomeKickoffCloser(["Honey"], ["Pollen"]), + /Honey and Pollen are taking longer than expected/, ); }); @@ -188,7 +188,7 @@ test("running teammates restart when their allowlist does not include the lead", assert.equal( welcomeTeammateNeedsRestart( { - ...bumble, + ...pollen, status: "running", respondTo: "allowlist", respondToAllowlist: [honey.pubkey], @@ -241,7 +241,7 @@ test("owner-only-access policy still restarts running teammates for runtime chan }); test("opener keeps partial-readiness warm and mentions only online teammates", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const introTeammates = selectWelcomeKickoffIntroTeammates( agentSet.teammates, [honey], @@ -258,12 +258,12 @@ test("opener keeps partial-readiness warm and mentions only online teammates", ( assert.doesNotMatch(input.content, /@@/); assert.doesNotMatch( input.content, - /Bumble.*trouble|couldn't start|taking longer/i, + /Pollen.*trouble|couldn't start|taking longer/i, ); }); test("opener greets the owner by name and tags their pubkey", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const owner = { pubkey: "owner-pubkey-hex", displayName: "Morgan" }; const input = buildWelcomeKickoffOpenerSendInput( agentSet, @@ -274,7 +274,7 @@ test("opener greets the owner by name and tags their pubkey", () => { assert.deepEqual(input.mentionPubkeys, [ honey.pubkey, - bumble.pubkey, + pollen.pubkey, owner.pubkey, ]); assert.match(input.content, /^Hi @Morgan, I'm Fizz\./); @@ -283,7 +283,7 @@ test("opener greets the owner by name and tags their pubkey", () => { }); test("opener falls back to an unnamed greeting when the display name is missing", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const owner = { pubkey: "owner-pubkey-hex", displayName: " " }; const input = buildWelcomeKickoffOpenerSendInput( agentSet, @@ -299,7 +299,7 @@ test("opener falls back to an unnamed greeting when the display name is missing" }); test("opener greets and tags the owner even when no teammates come online", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const input = buildWelcomeKickoffOpenerSendInput(agentSet, [], "welcome-1", { pubkey: "owner-pubkey-hex", displayName: "Morgan", @@ -311,7 +311,7 @@ test("opener greets and tags the owner even when no teammates come online", () = }); test("opener does not duplicate the owner pubkey if already mentioned", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const input = buildWelcomeKickoffOpenerSendInput( agentSet, [honey], @@ -323,12 +323,12 @@ test("opener does not duplicate the owner pubkey if already mentioned", () => { }); test("opener degrades to one seeded Fizz message when no teammate comes online", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const input = buildWelcomeKickoffOpenerSendInput(agentSet, [], "welcome-1"); assert.deepEqual(input.mentionPubkeys, []); assert.equal(input.additionalMarkers.length, 1); - assert.match(input.content, /I'm here with Honey and Bumble/); + assert.match(input.content, /I'm here with Honey and Pollen/); assert.match(input.content, /What can we help you build/); assert.doesNotMatch( input.content, @@ -337,11 +337,11 @@ test("opener degrades to one seeded Fizz message when no teammate comes online", }); test("readiness wait returns the subset that became online by the deadline", async () => { - const online = await waitForWelcomeTeammatesOnline([honey, bumble], { + const online = await waitForWelcomeTeammatesOnline([honey, pollen], { isCancelled: () => false, loadPresence: async () => ({ [honey.pubkey]: "online", - [bumble.pubkey]: "offline", + [pollen.pubkey]: "offline", }), pollMs: 0, waitMs: 0, @@ -363,7 +363,7 @@ function relayEvent({ id, pubkey, createdAt = 1, tags = [], content = "" }) { } test("closer classification sees replies that arrive during the final beat", async () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const opener = relayEvent({ id: "opener", pubkey: fizz.pubkey, @@ -374,7 +374,7 @@ test("closer classification sees replies that arrive during the final beat", asy const beforeBeat = classifyWelcomeKickoffResolution(events, opener, agentSet); assert.deepEqual( beforeBeat.unresolved.map((agent) => agent.name), - ["Honey", "Bumble"], + ["Honey", "Pollen"], ); const beat = waitForWelcomeKickoffBeat({ waitMs: 5 }); @@ -394,7 +394,7 @@ test("closer classification sees replies that arrive during the final beat", asy const afterBeat = classifyWelcomeKickoffResolution(events, opener, agentSet); assert.deepEqual( afterBeat.unresolved.map((agent) => agent.name), - ["Bumble"], + ["Pollen"], ); }); @@ -421,11 +421,11 @@ const kickoffOpener = relayEvent({ // opener and never the intros, and the closer stalled until the user happened // to click into the thread. Merging the opener's subtree in is the fix. test("intro replies reach the closer classification without the user opening the thread", () => { - const agentSet = { lead: fizz, teammates: [honey, bumble] }; + const agentSet = { lead: fizz, teammates: [honey, pollen] }; const channelEvents = [kickoffOpener]; const openerReplies = [ introReply("honey-intro", honey.pubkey, kickoffOpener.id), - introReply("bumble-intro", bumble.pubkey, kickoffOpener.id), + introReply("pollen-intro", pollen.pubkey, kickoffOpener.id), ]; // Pin the pre-fix behaviour: on the channel events alone, both teammates @@ -436,7 +436,7 @@ test("intro replies reach the closer classification without the user opening the kickoffOpener, agentSet, ).unresolved.map((agent) => agent.name), - ["Honey", "Bumble"], + ["Honey", "Pollen"], ); // With the subtree merged in, the same intros resolve the kickoff. @@ -455,12 +455,12 @@ test("merging the opener subtree never double-counts an already-visible reply", // An open thread feeds the same replies in through both sources. const merged = mergeKickoffEvents( [kickoffOpener, honeyIntro], - [honeyIntro, introReply("bumble-intro", bumble.pubkey, kickoffOpener.id)], + [honeyIntro, introReply("pollen-intro", pollen.pubkey, kickoffOpener.id)], ); assert.deepEqual( merged.map((event) => event.id), - ["opener", "honey-intro", "bumble-intro"], + ["opener", "honey-intro", "pollen-intro"], ); }); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 705e46e2c98..4215d183ac2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2408,9 +2408,9 @@ function resetMockPersonas(config?: E2eConfig) { }, { id: "builtin:bumble", - display_name: "Bumble", + display_name: "Pollen", avatar_url: null, - system_prompt: "You are Bumble.", + system_prompt: "You are Pollen.", }, ]; mockPersonas = builtInPersonas.map((persona) => ({ diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 7a3925eb693..e191d293045 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -237,14 +237,14 @@ test("catalog hides built-ins and shows the shared-agent empty state", async ({ await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("agents-library-personas")).toBeVisible(); - for (const personaName of ["Fizz", "Honey", "Bumble"]) { + for (const personaName of ["Fizz", "Honey", "Pollen"]) { await expect(page.getByTestId("agents-library-personas")).toContainText( personaName, ); } await openPersonaCatalog(page); - for (const personaName of ["Fizz", "Honey", "Bumble"]) { + for (const personaName of ["Fizz", "Honey", "Pollen"]) { await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( personaName, ); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 801000189f6..fc4dd8c5464 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -2429,7 +2429,7 @@ test("agent profile popover shows its owner", async ({ page }) => { searchProfiles: [ { pubkey: OWNED_AGENT_PROFILE_PUBKEY, - displayName: "Bumble", + displayName: "Pollen", ownerPubkey: TEST_IDENTITIES.bob.pubkey, isAgent: true, }, @@ -2440,16 +2440,16 @@ test("agent profile popover shows its owner", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - await emitMockMessage(page, "general", "Bumble checking in.", { + await emitMockMessage(page, "general", "Pollen checking in.", { pubkey: OWNED_AGENT_PROFILE_PUBKEY, }); await waitForTimelineSettled(page); - const bumbleMessage = page + const pollenMessage = page .getByTestId("message-row") - .filter({ hasText: "Bumble checking in." }) + .filter({ hasText: "Pollen checking in." }) .first(); - await bumbleMessage.locator("button").first().hover(); + await pollenMessage.locator("button").first().hover(); const profilePopover = page.locator( '[data-testid="user-profile-popover"][data-state="open"]', @@ -2469,7 +2469,7 @@ test("agent profile popover labels an agent owned by the viewer as you", async ( searchProfiles: [ { pubkey: OWNED_AGENT_PROFILE_PUBKEY, - displayName: "Bumble", + displayName: "Pollen", ownerPubkey: MOCK_VIEWER_PUBKEY, isAgent: true, }, @@ -2480,16 +2480,16 @@ test("agent profile popover labels an agent owned by the viewer as you", async ( await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - await emitMockMessage(page, "general", "Bumble checking in.", { + await emitMockMessage(page, "general", "Pollen checking in.", { pubkey: OWNED_AGENT_PROFILE_PUBKEY, }); await waitForTimelineSettled(page); - const bumbleMessage = page + const pollenMessage = page .getByTestId("message-row") - .filter({ hasText: "Bumble checking in." }) + .filter({ hasText: "Pollen checking in." }) .first(); - await bumbleMessage.locator("button").first().hover(); + await pollenMessage.locator("button").first().hover(); const profilePopover = page.locator( '[data-testid="user-profile-popover"][data-state="open"]', @@ -2509,7 +2509,7 @@ test("agent profile popover falls back to the owner's pubkey", async ({ searchProfiles: [ { pubkey: OWNED_AGENT_PROFILE_PUBKEY, - displayName: "Bumble", + displayName: "Pollen", ownerPubkey: CASEY_PROFILE_PUBKEY, isAgent: true, }, @@ -2520,16 +2520,16 @@ test("agent profile popover falls back to the owner's pubkey", async ({ await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "general"); - await emitMockMessage(page, "general", "Bumble checking in.", { + await emitMockMessage(page, "general", "Pollen checking in.", { pubkey: OWNED_AGENT_PROFILE_PUBKEY, }); await waitForTimelineSettled(page); - const bumbleMessage = page + const pollenMessage = page .getByTestId("message-row") - .filter({ hasText: "Bumble checking in." }) + .filter({ hasText: "Pollen checking in." }) .first(); - await bumbleMessage.locator("button").first().hover(); + await pollenMessage.locator("button").first().hover(); const profilePopover = page.locator( '[data-testid="user-profile-popover"][data-state="open"]', diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 2d8e58492fe..b6b43d5e19d 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -3217,7 +3217,7 @@ test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => { "Hi Morty QA, I'm Fizz. Welcome to Buzz.", ); await expect(page.getByTestId("message-timeline")).toContainText( - "Honey and Bumble, introduce yourselves", + "Honey and Pollen, introduce yourselves", ); }); diff --git a/docs/welcome-kickoff-silent-failures.md b/docs/welcome-kickoff-silent-failures.md index 58a4a5fa958..df9d5027bf6 100644 --- a/docs/welcome-kickoff-silent-failures.md +++ b/docs/welcome-kickoff-silent-failures.md @@ -68,8 +68,8 @@ prompt fix in §2 and the closer fix in §1 are the same change in two places. ## 1. Wrong story: the closer speaks on a timer **Status: fixed on this branch. Observed 2026-07-18, 14:26.** Opener at 2:26. At 2:26+15s Fizz -posted *"Honey and Bumble are taking longer than expected. I'm still here to -help."* Honey and Bumble posted good intros at 2:27. The false story was never +posted *"Honey and Pollen are taking longer than expected. I'm still here to +help."* Honey and Pollen posted good intros at 2:27. The false story was never corrected, because it was already stamped final. ### Mechanism @@ -79,7 +79,7 @@ corrected, because it was already stamped final. 2. It fires. `classifyWelcomeKickoffResolution` (`:292`) splits teammates into `failed` (fact-based, via `failedAfterKickoff`) and `unresolved` (**merely no intro seen yet**). -3. `unresolved.length > 0` → `buildWelcomeKickoffCloser([], ["Honey","Bumble"])` +3. `unresolved.length > 0` → `buildWelcomeKickoffCloser([], ["Honey","Pollen"])` → the "taking longer" text + the CTA (`:253`). 4. It posts **with `closerMarker`** (`sendWelcomeKickoffCloser`, `:443`). That marker is **terminal**: every later pass early-returns on it (`:703`) and the @@ -158,9 +158,9 @@ Codex specifically. Observed on the Codex runtime (`codex-acp`), never reproduced on Claude Code. 21+ replies deep, each an acknowledgement of the previous acknowledgement: -> **Bumble:** `@Fizz` parked; no further replies from me until there's work. +> **Pollen:** `@Fizz` parked; no further replies from me until there's work. > **Honey:** `@Fizz` understood. I won't reply again unless there's a task for me. -> **Fizz:** `@Honey` `@Bumble` acknowledged — stay parked until `@morgan` brings a real task. +> **Fizz:** `@Honey` `@Pollen` acknowledged — stay parked until `@morgan` brings a real task. **The content was the tell: every agent was trying to end the conversation, and announcing it is what kept it alive.** The agents were not malfunctioning — they @@ -298,7 +298,7 @@ All hard-coded client-side; only teammate intro replies are LLM-generated. |---|---|---|---| | 1 | Provider fallback ("connect to an AI provider in Settings…") | Readiness check fails before kickoff | Fizz (`provider-required.v1`) | | 2 | Happy-path opener | Team online | Fizz (`opener.v1`) | -| 3 | Degraded opener ("I'm here with Honey and Bumble…") | Fizz online, zero teammates online within 60s | Fizz (opener + closer markers) | +| 3 | Degraded opener ("I'm here with Honey and Pollen…") | Fizz online, zero teammates online within 60s | Fizz (opener + closer markers) | | 4 | Closer variants (clean / failed / slow) | 3s beat after intros resolve, **or the 120s intro backstop** — see [§1](#1-wrong-story-the-closer-speaks-on-a-timer) | Fizz (`closer.v1`) | | 5 | Setup-mode nudge ("here's what you still need to configure") | Agent spawns but requirements check fails (e.g. missing API key) | The agent process itself (buzz-acp setup-listener mode) | @@ -447,7 +447,7 @@ the human: | Turn | Trigger | `p` tags | Classified | |---|---|---|---| -| Honey/Bumble | Fizz: *"…until `@morgan` brings a real task"* | Honey, Bumble, **morgan** | **human → MUST reply** | +| Honey/Pollen | Fizz: *"…until `@morgan` brings a real task"* | Honey, Pollen, **morgan** | **human → MUST reply** | | Fizz | Honey: *"@Fizz understood"* | Fizz | agent → optional | It exempts only the leg that happens not to name the human — cutting 1 of 3 legs From 85bacea52b8359999f22c6ac07207a130809c488 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Mon, 17 Aug 2026 11:48:38 -0700 Subject: [PATCH 08/16] Remove GitHub security advisory commitment (#6144) Removes the promise in `SECURITY.md` to publish a GitHub Security Advisory after every security fix is released. The disclosure policy continues to state that Buzz follows coordinated disclosure and credits reporters unless they request anonymity. Checked with `git diff --check`. Signed-off-by: Jordan Mecom --- SECURITY.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 09ea73022b3..45202b10fbc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -121,6 +121,4 @@ We use `cargo audit` in CI to scan for known vulnerabilities in dependencies. ## Disclosure Policy We follow [coordinated disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure). -Once a fix is ready and released, we will publish a security advisory on -GitHub describing the vulnerability, its impact, and the fix. Reporters will -be credited unless they request anonymity. +Reporters will be credited unless they request anonymity. From a282e0643fe0f14ace4d9b57ead99d0635e38995 Mon Sep 17 00:00:00 2001 From: Illumi <152645269+Illuminfti@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:49:52 +0100 Subject: [PATCH 09/16] fix(cli): keep project replacement timestamps at or after wall clock (#5666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #5665. `next_timestamp` in `crates/buzz-cli/src/commands/projects.rs` computed a replacement's `created_at` as `head.created_at + 1`. The relay's ingest path rejects events more than ±900s from server time (`MAX_TIMESTAMP_DRIFT_SECS` in `crates/buzz-relay/src/handlers/ingest.rs`), so: - `projects update` on any project whose head is older than 15 minutes fails with `relay error 400: invalid: event timestamp too far from server time` (live repro in #5665); - inside the window, replacements are recorded at `head+1` — seconds-to-minutes in the past — so a concurrent wall-clock writer silently wins LWW and audit timestamps misstate when the write happened. ## Change `next_timestamp` now returns `max(now, head.created_at + 1)`: strictly after the observed head (preserving the dominate-the-head guarantee for skewed/future heads), never behind the wall clock. This mirrors the relay's own replacement-authoring pattern (`now.max(head+1)` in `side_effects.rs`). ## Testing - `cargo test -p buzz-cli --lib` — 344 passed; adds `next_timestamp_uses_wall_clock_when_head_is_stale`, and the existing far-future-head test still holds (`head+1` wins when head > now) - `cargo clippy -p buzz-cli --all-targets` / `cargo fmt --check` — clean - Live before/after on a self-hosted relay: vanilla CLI fails on a 2h-aged head; with this change the same update is accepted and the head lands at wall clock. Same failure family as #2876 (`repos protect` vs the drift window) — that path is not touched here. --------- Signed-off-by: Ika Minami Signed-off-by: Ravneet Arora Co-authored-by: Ika Minami Co-authored-by: Ravneet Arora --- crates/buzz-cli/src/commands/projects.rs | 88 +++++++++++++++--------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index 32056bc6991..00e6f3efb96 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -4,8 +4,10 @@ //! 1. Fetch the caller's own live head via `kinds:[30621] + authors:[self] + #d:[slug]`. //! 2. Mutate the tag set (strip `auth`, apply change). //! 3. Re-validate the full envelope through Layer A before submitting. -//! 4. Set `created_at = head.created_at + 1` (never wall-clock) to avoid -//! overwriting a concurrently advancing head. +//! 4. Set `created_at = max(client_now, head.created_at + 1)` so the +//! replacement dominates the observed head and uses wall clock for +//! ordinary stale heads. Unusually future heads may still hit the relay's +//! timestamp-drift guard until time advances. //! //! Limitations recorded in this phase: //! - Relay hints are read-preserved but not authored (`--repo` carries @@ -136,13 +138,17 @@ async fn submit_project( // ── Build helpers ───────────────────────────────────────────────────────────── -/// Advance the `created_at` counter off an observed head. -fn next_timestamp(head: &Event) -> Result { - head.created_at +/// Choose the later of client wall clock and the instant after the observed head. +/// +/// The relay remains authoritative for timestamp drift: a sufficiently future +/// head can require a timestamp that the relay will temporarily reject. +fn next_timestamp(head: &Event, now: Timestamp) -> Result { + let after_head = head + .created_at .as_secs() .checked_add(1) - .map(Timestamp::from) - .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into())) + .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into()))?; + Ok(Timestamp::from(after_head.max(now.as_secs()))) } /// Strip `auth` from a tag list and pass the resulting envelope through @@ -312,7 +318,7 @@ pub async fn cmd_add_repo( let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; - let next_ts = next_timestamp(&head)?; + let next_ts = next_timestamp(&head, Timestamp::now())?; // Build the new tag set: keep existing tags (including hinted members), // append new members only if not already present (by coordinate). @@ -366,7 +372,7 @@ pub async fn cmd_remove_repo( let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; - let next_ts = next_timestamp(&head)?; + let next_ts = next_timestamp(&head, Timestamp::now())?; // Verify all requested repos exist in the project. let existing_coords: std::collections::HashSet = head @@ -458,7 +464,7 @@ pub async fn cmd_update( let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; - let next_ts = next_timestamp(&head)?; + let next_ts = next_timestamp(&head, Timestamp::now())?; // Build the new tag set. For each singleton metadata field: // - setter present: replace value (strip old, append new) @@ -515,7 +521,7 @@ pub async fn cmd_update( /// /// Head-based and verified: /// 1. Fetch own live head — `NotFound` if absent. -/// 2. Build tombstone at `head.created_at + 1`. +/// 2. Build tombstone at `max(client_now, head.created_at + 1)`. /// 3. Submit. /// 4. Re-query the coordinate; if a newer head survived → `Conflict`. pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> { @@ -524,7 +530,7 @@ pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> let head = fetch_own_project(client, slug) .await? .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; - let next_ts = next_timestamp(&head)?; + let next_ts = next_timestamp(&head, Timestamp::now())?; let pubkey_hex = client.keys().public_key().to_hex(); let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug) @@ -1003,31 +1009,51 @@ mod tests { // ── next_timestamp ordering ─────────────────────────────────────────────── - /// `next_timestamp` must return `head.created_at + 1` regardless of the wall - /// clock. NIP-MP Deletion rule: a tombstone older than the live head does - /// NOT remove it, so we must advance strictly off the observed head — never - /// use wall-clock time, which could be behind a head that was bumped - /// multiple times in the same second. - #[test] - fn next_timestamp_returns_head_plus_one_when_head_is_ahead_of_wall_clock() { - // Build a minimal signed event with a created_at far in the future. + fn project_head_at(created_at: u64) -> Event { let keys = nostr::Keys::generate(); - let far_future_ts = Timestamp::from(9_999_999_999u64); // year 2286 let tags = vec![ make_test_tag(&["d", "platform"]), make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]), ]; - let builder = rebuild_project("", tags, far_future_ts).expect("valid head envelope"); - let head = builder.sign_with_keys(&keys).expect("sign"); - // Verify the event actually has our future timestamp. - assert_eq!(head.created_at, far_future_ts); + rebuild_project("", tags, Timestamp::from(created_at)) + .expect("valid head envelope") + .sign_with_keys(&keys) + .expect("sign") + } - // next_timestamp must return far_future + 1, not now(). - let next = next_timestamp(&head).expect("no overflow"); - assert_eq!( - next.as_secs(), - far_future_ts.as_secs() + 1, - "tombstone must be strictly after head, even when head is far in the future" + #[test] + fn next_timestamp_uses_later_of_wall_clock_and_after_head() { + let cases = [ + ("stale head", 100, 1_000, 1_000), + ("head equal to now", 1_000, 1_000, 1_001), + ("future head", 1_500, 1_000, 1_501), + ("last timestamp inside future boundary", 1_899, 1_000, 1_900), + ( + "future boundary cannot be dominated inside the window", + 1_900, + 1_000, + 1_901, + ), + ]; + + for (name, head_ts, now, expected) in cases { + let head = project_head_at(head_ts); + let next = next_timestamp(&head, Timestamp::from(now)).expect("no overflow"); + + assert_eq!(next.as_secs(), expected, "case: {name}"); + } + } + + #[test] + fn next_timestamp_rejects_overflowing_head() { + let head = project_head_at(u64::MAX); + + let err = next_timestamp(&head, Timestamp::from(1_000u64)) + .expect_err("maximum timestamp cannot be advanced"); + + assert!( + matches!(err, CliError::Other(ref message) if message == "project timestamp cannot be advanced"), + "unexpected error: {err}" ); } From 57feca2f20bb3434d70ce770b9ed98b1c1472332 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 17 Aug 2026 17:44:43 -0400 Subject: [PATCH 10/16] fix(desktop): repair dropped team membership links at boot and on edit (#5904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two membership-propagation defects let an agent team silently lose members — both observed live on Will's store (Sietch Tabr), not hypothetical. **Stale `persona_ids` dropped on save.** Team records written before persona ids were namespaced hold bare slugs (`thufir`) instead of the namespaced id (`sietch-tabr:thufir`). Nothing rewrites them, and the interactive save path (`ensure_persona_ids_are_active`) *drops* any id it cannot resolve — so the next in-app save shrinks the team. This nuked four of five Sietch Tabr members. **`team_id` drifts from team membership.** Team instructions are injected at spawn by matching `record.team_id` (`spawn_snapshot::effective_team_instructions`), so an instance's binding must track its persona's membership. It drifts two ways: adding a persona to a team leaves the persona's already-running instances at `team_id: null` (a member in the roster but not in behavior — seen twice, Gurney and Hayt), and removing a persona while keeping its agents leaves the kept instance bound to a team that no longer lists it (still drawing that team's instructions at spawn). ## Fix A boot migration (`migration/team_membership.rs`) heals existing stores in one pass over `teams.json` + `managed-agents.json`: - **Rewrite stale ids.** A stale id is one no definition slug resolves. Its target is the definition whose `source_team_persona_slug` equals the bare slug, scoped to the team's source team (via `source_dir` for a directory-backed team, or the unique `source_team` among resolvable members for a detached one). Rewrite only when exactly one candidate matches; zero or many leave the id in place — strictly safer than the save path, which drops it. - **Repair `team_id`.** Backfill an instance whose persona is a team member but whose own binding is unset, and heal a stale binding whose team no longer lists the persona (re-point when exactly one *other* team claims it, otherwise unbind). Both directions gate on single-team evidence — a persona spanning several teams has none (JSON team order is not ownership), so it is left as-is and logged. A binding whose team still lists the persona is authoritative and never touched. Runs BEFORE `detach_directory_backed_teams` (so a not-yet-detached team can still be scoped by its `source_dir`) and before any UI save can drop an id. Rewrite-or-leave converges to a fixed point, so a second boot is a no-op; the store is backed up once before either write. The edit path (`commands/teams.rs`) propagates a membership change to live instances immediately, without waiting for the next boot, scoped to the delta between the pre-edit and post-edit rosters: - **Added personas** (on the team now, not before) backfill `team_id` on their unbound instances. An explicit add is legitimate binding evidence even for a persona shared across teams — unlike the order-blind boot case. - **Removed personas** (on the team before, not now) clear `team_id` on instances bound to *this* team (bindings to other teams are untouched), so a "keep agents" removal stops feeding a kept instance the old team's instructions. - **Delta-scoping keeps a metadata-only edit inert:** with no roster change, no instance is re-pointed — a shared unbound persona is never silently bound to whichever team was edited last. Propagation is best-effort after the authoritative `save_teams` (mirroring `retain_team_pending`): the team already exists on disk, and boot repair is the designed retry for a stale/unset binding, so a secondary `managed-agents.json` write failure no longer fails a command whose team write succeeded — which would otherwise let a UI retry mint a duplicate team. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- .../src/commands/personas/inbound.rs | 57 +- .../personas/inbound/inbound_tests.rs | 170 +++++ desktop/src-tauri/src/commands/teams.rs | 466 ++++++++++++- desktop/src-tauri/src/commands/workspace.rs | 29 +- desktop/src-tauri/src/event_sync.rs | 64 +- .../src/event_sync_team_events_tests.rs | 129 ++++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + desktop/src-tauri/src/migration.rs | 14 +- desktop/src-tauri/src/migration/detach.rs | 22 +- .../src/migration/team_membership.rs | 353 ++++++++++ .../src/migration/team_membership_tests.rs | 625 ++++++++++++++++++ .../src-tauri/src/migration_test_support.rs | 14 + 12 files changed, 1874 insertions(+), 70 deletions(-) create mode 100644 desktop/src-tauri/src/migration/team_membership.rs create mode 100644 desktop/src-tauri/src/migration/team_membership_tests.rs diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 5c38373f7cf..42f720915a3 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -255,8 +255,14 @@ fn reconcile_inbound_persona_event_blocking( } KIND_TEAM => { let mut teams = load_teams(&app)?; - apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?); - save_teams(&app, &teams)?; + commit_inbound_team( + &mut teams, + d_tag, + team_content_from_event(&event)?, + |teams| save_teams(&app, teams), + || load_managed_agents(&app), + |records| save_managed_agents(&app, records), + )?; } KIND_MANAGED_AGENT => { let mut agents = load_managed_agents(&app)?; @@ -584,6 +590,53 @@ fn apply_inbound_managed_agent( false } +/// In-memory core of the inbound `KIND_TEAM` reconcile: capture the matched +/// team's roster *before* applying the inbound projection, apply it, persist +/// teams authoritatively, then propagate the prior→current membership delta to +/// live instances best-effort — the same binding semantics the local +/// create/update commands use. Without this, a 30176 team edit from another +/// device lands on `teams.json` but never touches `ManagedAgentRecord.team_id`: +/// an added persona's running instances stay unbound (member in roster, not in +/// behavior) and a removed persona's instances keep drawing the old team's +/// instructions at spawn until restart. +/// +/// A no-match insert has no prior roster, so its whole roster is the added +/// delta — symmetric with `commit_team_create`. Injected persistence keeps it +/// `AppHandle`-free so the prior-roster capture and delta direction are +/// unit-testable; a `persist_teams` error propagates, agent IO is best-effort +/// (mirrors the local command path: the authoritative team write already +/// landed, and boot repair is the designed retry for a stale binding). +fn commit_inbound_team( + teams: &mut Vec, + d_tag: String, + inbound: TeamEventContent, + persist_teams: impl FnOnce(&[TeamRecord]) -> Result<(), String>, + load_agents: impl FnOnce() -> Result, String>, + save_agents: impl FnOnce(&[ManagedAgentRecord]) -> Result<(), String>, +) -> Result<(), String> { + let team_id = d_tag.clone(); + let previous_persona_ids = teams + .iter() + .find(|record| record.id == team_id) + .map(|record| record.persona_ids.clone()) + .unwrap_or_default(); + apply_inbound_team(teams, d_tag, inbound); + let current_persona_ids = teams + .iter() + .find(|record| record.id == team_id) + .map(|record| record.persona_ids.clone()) + .unwrap_or_default(); + persist_teams(teams)?; + crate::commands::teams::propagate_membership_best_effort( + &team_id, + &previous_persona_ids, + ¤t_persona_ids, + load_agents, + save_agents, + ); + Ok(()) +} + /// Merge an inbound kind:30176 team projection into the local set. /// /// Matches the local record whose `id` equals the event's d-tag (the d-tag IS diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index e7834f8231d..c7235bd034a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -546,6 +546,176 @@ fn inbound_team_no_match_inserts_idempotently() { assert_eq!(teams.len(), 2, "re-receive of inserted team no-ops"); } +// ── Inbound team → membership propagation (commit_inbound_team wiring) ───── + +use std::cell::RefCell; + +/// A running instance of `persona_id`, optionally bound to a team. +fn team_instance(seed: char, persona_id: &str, team_id: Option<&str>) -> ManagedAgentRecord { + let mut record = local_agent(); + record.pubkey = seed.to_string().repeat(64); + record.name = persona_id.to_string(); + record.persona_id = Some(persona_id.to_string()); + record.team_id = team_id.map(str::to_string); + record +} + +/// An inbound team edit that ADDS a persona must bind that persona's unbound +/// running instances to the team — exactly like a local `update_team`. Without +/// the propagation wiring the instance stays unbound (member in roster, not in +/// behavior) until restart. +#[test] +fn inbound_team_add_binds_unbound_instance_through_wiring() { + let mut teams = vec![local_team()]; + teams[0].persona_ids = vec!["p-existing".to_string()]; + let existing = vec![ + team_instance('a', "p-added", None), + team_instance('b', "p-existing", Some(TEAM_ID)), + ]; + let saved = RefCell::new(None); + + commit_inbound_team( + &mut teams, + TEAM_ID.to_string(), + TeamEventContent { + name: "Team".to_string(), + description: None, + instructions: None, + persona_ids: Some(vec!["p-existing".to_string(), "p-added".to_string()]), + }, + |_| Ok(()), + || Ok(existing.clone()), + |records| { + *saved.borrow_mut() = Some(records.to_vec()); + Ok(()) + }, + ) + .expect("inbound add succeeds"); + + let saved = saved + .borrow() + .clone() + .expect("add must save the agent store"); + assert_eq!( + saved[0].team_id.as_deref(), + Some(TEAM_ID), + "the added persona's unbound instance is bound to the team" + ); + assert_eq!( + saved[1].team_id.as_deref(), + Some(TEAM_ID), + "an instance already on the team is untouched" + ); +} + +/// An inbound team edit that REMOVES a persona ("keep agents") must detach that +/// persona's instances bound to this team, so a kept instance stops drawing the +/// team's instructions at spawn. +#[test] +fn inbound_team_removal_detaches_instance_through_wiring() { + let mut teams = vec![local_team()]; + teams[0].persona_ids = vec!["p-removed".to_string()]; + let existing = vec![team_instance('a', "p-removed", Some(TEAM_ID))]; + let saved = RefCell::new(None); + + commit_inbound_team( + &mut teams, + TEAM_ID.to_string(), + TeamEventContent { + name: "Team".to_string(), + description: None, + instructions: None, + persona_ids: Some(vec![]), + }, + |_| Ok(()), + || Ok(existing.clone()), + |records| { + *saved.borrow_mut() = Some(records.to_vec()); + Ok(()) + }, + ) + .expect("inbound removal succeeds"); + + let saved = saved + .borrow() + .clone() + .expect("removal must save the agent store"); + assert_eq!( + saved[0].team_id, None, + "the removed persona's instance is detached from the team" + ); +} + +/// An inbound edit that omits `persona_ids` (a pre-always-publish client) +/// preserves local membership, so the delta is empty and no instance is +/// re-pointed — a metadata-only inbound edit must not disturb bindings. +#[test] +fn inbound_team_omitted_roster_leaves_bindings_untouched() { + let mut teams = vec![local_team()]; + teams[0].persona_ids = vec!["p-a".to_string()]; + let existing = vec![team_instance('a', "p-a", None)]; + let saved = RefCell::new(None); + + commit_inbound_team( + &mut teams, + TEAM_ID.to_string(), + team_content_omitting_optional_fields("Renamed"), + |_| Ok(()), + || Ok(existing.clone()), + |records| { + *saved.borrow_mut() = Some(records.to_vec()); + Ok(()) + }, + ) + .expect("inbound metadata-only edit succeeds"); + + assert!( + saved.borrow().is_none(), + "an empty membership delta writes nothing to the agent store" + ); +} + +/// A failing agent-store write after the authoritative `save_teams` is +/// swallowed: the inbound reconcile still succeeds (boot repair is the retry), +/// so a secondary-store hiccup never aborts an inbound event whose team write +/// already landed. +#[test] +fn inbound_team_swallows_agent_store_failure() { + let mut teams = vec![local_team()]; + teams[0].persona_ids = vec![]; + commit_inbound_team( + &mut teams, + TEAM_ID.to_string(), + TeamEventContent { + name: "Team".to_string(), + description: None, + instructions: None, + persona_ids: Some(vec!["p-added".to_string()]), + }, + |_| Ok(()), + || Err("agent store unreadable".to_string()), + |_| Ok(()), + ) + .expect("inbound reconcile swallows secondary-store failure"); +} + +/// A `persist_teams` error propagates — the authoritative team write failing is +/// a real reconcile failure, unlike best-effort agent IO. +#[test] +fn inbound_team_propagates_persist_teams_error() { + let mut teams = vec![local_team()]; + let err = commit_inbound_team( + &mut teams, + TEAM_ID.to_string(), + team_content("Team"), + |_| Err("disk full".to_string()), + || Ok(vec![]), + |_| Ok(()), + ) + .expect_err("a failed team persist must propagate"); + assert_eq!(err, "disk full"); +} + // ── Tombstone (kind:5) consume ──────────────────────────────────────────── fn deletion_event(coord: &str) -> nostr::Event { diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 4377ddaa434..e17c5bdb247 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -4,8 +4,9 @@ use uuid::Uuid; use crate::{ app_state::AppState, managed_agents::{ - delete_team_with_cascade, ensure_persona_ids_are_active, load_personas, load_teams, - save_teams, try_regenerate_nest, CreateTeamRequest, TeamRecord, UpdateTeamRequest, + delete_team_with_cascade, ensure_persona_ids_are_active, load_managed_agents, + load_personas, load_teams, save_managed_agents, save_teams, try_regenerate_nest, + CreateTeamRequest, TeamRecord, UpdateTeamRequest, }, util::now_iso, }; @@ -25,6 +26,174 @@ fn trim_optional(value: Option) -> Option { }) } +/// Propagate a team's membership *change* to its members' already-running +/// instances, best-effort. Loads the agent store, applies the roster delta via +/// [`apply_team_membership_delta`], and re-saves only when something changed; +/// any load/save error is logged and swallowed. Called after the authoritative +/// `save_teams` succeeds — the team already exists on disk and boot repair is +/// the designed retry for a stale/unset binding, so a secondary-store hiccup +/// must not fail a command whose team write already landed (a UI retry would +/// then mint a duplicate team). +/// +/// `load_agents`/`save_agents` are injected so the command wiring (prior-roster +/// capture, delta direction, and this best-effort policy) is unit-testable +/// without an `AppHandle`; the commands pass the real store IO. +/// +/// Shared with the inbound reconcile path (`commands::personas::inbound`): a +/// 30176 team edit arriving from another device must bind/detach instances the +/// same way a local edit does, so both call this one wrapper. +pub(in crate::commands) fn propagate_membership_best_effort( + team_id: &str, + previous_persona_ids: &[String], + current_persona_ids: &[String], + load_agents: impl FnOnce() -> Result, String>, + save_agents: impl FnOnce(&[crate::managed_agents::ManagedAgentRecord]) -> Result<(), String>, +) { + let result = (|| -> Result<(), String> { + let mut records = load_agents()?; + if apply_team_membership_delta( + &mut records, + team_id, + previous_persona_ids, + current_persona_ids, + ) { + save_agents(&records)?; + } + Ok(()) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: team-membership-propagate: {e}"); + } +} + +/// In-memory core of [`create_team`]: push the built team, persist teams +/// authoritatively, then propagate its whole roster (no prior members ⇒ the +/// whole roster is the added delta) to live instances best-effort. Decoupled +/// from the `AppHandle` shell via injected persistence so the create wiring is +/// unit-testable. A `persist_teams` error propagates; agent IO is best-effort. +fn commit_team_create( + teams: &mut Vec, + team: TeamRecord, + persist_teams: impl FnOnce(&[TeamRecord]) -> Result<(), String>, + load_agents: impl FnOnce() -> Result, String>, + save_agents: impl FnOnce(&[crate::managed_agents::ManagedAgentRecord]) -> Result<(), String>, +) -> Result { + teams.push(team.clone()); + persist_teams(teams)?; + propagate_membership_best_effort(&team.id, &[], &team.persona_ids, load_agents, save_agents); + Ok(team) +} + +/// In-memory core of [`update_team`]: mutate the matching team, capturing its +/// roster *before* the edit, persist teams authoritatively, then propagate the +/// prior→current delta to live instances best-effort. The prior-roster capture +/// and its use as the delta baseline live here — not at a command call site — +/// so a miswire to the wrong baseline is caught by a test. Injected persistence +/// keeps it `AppHandle`-free; a `persist_teams` error propagates, agent IO is +/// best-effort. Returns the updated team. +#[allow(clippy::too_many_arguments)] +fn commit_team_update( + teams: &mut [TeamRecord], + id: &str, + name: String, + description: Option, + instructions: Option, + persona_ids: Vec, + now: String, + persist_teams: impl FnOnce(&[TeamRecord]) -> Result<(), String>, + load_agents: impl FnOnce() -> Result, String>, + save_agents: impl FnOnce(&[crate::managed_agents::ManagedAgentRecord]) -> Result<(), String>, +) -> Result { + let team = teams + .iter_mut() + .find(|record| record.id == id) + .ok_or_else(|| format!("team {id} not found"))?; + + // Capture the pre-edit roster before mutation: the propagation delta + // (added → backfill, removed → detach) is computed against it. + let previous_persona_ids = team.persona_ids.clone(); + team.name = name; + team.description = description; + team.instructions = instructions; + team.persona_ids = persona_ids; + team.updated_at = now; + + let updated = team.clone(); + persist_teams(teams)?; + propagate_membership_best_effort( + &updated.id, + &previous_persona_ids, + &updated.persona_ids, + load_agents, + save_agents, + ); + Ok(updated) +} + +/// Pure core of the membership propagation: apply the roster delta to `records` +/// in place and report whether anything changed. Decoupled from the store IO so +/// the binding rules are unit-testable. +/// +/// Two directions, keyed on the delta between the pre-edit and post-edit +/// rosters: +/// +/// - **Added** (`current` but not `previous`): backfill `team_id` on the +/// persona's *unbound* instances, so an added persona spawns with the team's +/// instructions (`spawn_snapshot::effective_team_instructions` keys on +/// `record.team_id`). Only an unset field is set — a shared persona keeps an +/// existing binding — and an explicit add is legitimate binding evidence even +/// when the persona belongs to several teams. +/// - **Removed** (`previous` but not `current`): clear `team_id` on instances +/// bound to *this* team, so a "keep agents" removal stops feeding a kept +/// instance the instructions of a team it no longer belongs to. Bindings to +/// other teams are untouched. +/// +/// Delta-scoping is what keeps a metadata-only edit inert: with no roster +/// change both sets are empty and no instance is re-pointed — a shared unbound +/// persona is not silently bound to whichever team was last edited. `create` +/// has no prior roster, so it passes an empty `previous` and the whole roster is +/// "added" (the pre-fix whole-roster backfill). A persona both removed and +/// re-added in one edit appears in neither set (set difference, not +/// operation order), so its binding is left as-is. +fn apply_team_membership_delta( + records: &mut [crate::managed_agents::ManagedAgentRecord], + team_id: &str, + previous_persona_ids: &[String], + current_persona_ids: &[String], +) -> bool { + let added: Vec<&str> = current_persona_ids + .iter() + .filter(|id| !previous_persona_ids.iter().any(|p| p == *id)) + .map(String::as_str) + .collect(); + let removed: Vec<&str> = previous_persona_ids + .iter() + .filter(|id| !current_persona_ids.iter().any(|p| p == *id)) + .map(String::as_str) + .collect(); + if added.is_empty() && removed.is_empty() { + return false; + } + + let mut changed = false; + for record in records.iter_mut() { + if record.pubkey.is_empty() { + continue; + } + let Some(persona_id) = record.persona_id.as_deref() else { + continue; + }; + if record.team_id.is_none() && added.contains(&persona_id) { + record.team_id = Some(team_id.to_string()); + changed = true; + } else if record.team_id.as_deref() == Some(team_id) && removed.contains(&persona_id) { + record.team_id = None; + changed = true; + } + } + changed +} + /// Retain a freshly authored team event in the local store, flagged for relay /// sync. Called inside a command's `managed_agents_store_lock`-held body after /// `save_teams`; the background flush loop publishes it out-of-band. @@ -171,8 +340,13 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result Result Result) -> ManagedAgentRecord { + let mut record = serde_json::from_value::(serde_json::json!({ + "pubkey": seed.to_string().repeat(64), + "name": persona_id, + "persona_id": persona_id, + "relay_url": "ws://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "prompt", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + })) + .unwrap(); + record.team_id = team_id.map(str::to_string); + record + } + + fn ids(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + /// A metadata-only edit (no roster change) never re-points an instance — + /// including an unbound instance of a persona this team shares with another. + #[test] + fn metadata_only_edit_leaves_bindings_untouched() { + let mut records = vec![instance('a', "duncan", None)]; + let roster = ids(&["duncan"]); + assert!(!apply_team_membership_delta( + &mut records, + "team-a", + &roster, + &roster + )); + assert_eq!(records[0].team_id, None); + } + + /// Only the *added* persona's unbound instance is bound; an untouched member + /// already present in the previous roster is not re-pointed. + #[test] + fn added_persona_backfills_only_its_unbound_instance() { + let mut records = vec![ + instance('a', "duncan", None), + instance('b', "paul", Some("team-b")), + ]; + assert!(apply_team_membership_delta( + &mut records, + "team-a", + &ids(&["paul"]), + &ids(&["paul", "duncan"]), + )); + assert_eq!(records[0].team_id.as_deref(), Some("team-a")); + // Paul was already on the team and bound elsewhere — untouched. + assert_eq!(records[1].team_id.as_deref(), Some("team-b")); + } + + /// An added persona binds even when shared across teams: an explicit add is + /// legitimate evidence (unlike the boot-repair's order-blind case). + #[test] + fn added_shared_persona_binds_to_the_edited_team() { + let mut records = vec![instance('a', "duncan", None)]; + assert!(apply_team_membership_delta( + &mut records, + "team-a", + &[], + &ids(&["duncan"]), + )); + assert_eq!(records[0].team_id.as_deref(), Some("team-a")); + } + + /// Removing a persona ("keep agents") clears its binding to *this* team so a + /// kept instance stops drawing the team's instructions at spawn. + #[test] + fn removed_persona_detaches_instance_bound_to_this_team() { + let mut records = vec![instance('a', "duncan", Some("team-a"))]; + assert!(apply_team_membership_delta( + &mut records, + "team-a", + &ids(&["duncan"]), + &[], + )); + assert_eq!(records[0].team_id, None); + } + + /// Removal only clears a binding pointing at *this* team — an instance of + /// the same persona bound to a different team is left alone. + #[test] + fn removed_persona_leaves_other_team_binding_untouched() { + let mut records = vec![instance('a', "duncan", Some("team-b"))]; + assert!(!apply_team_membership_delta( + &mut records, + "team-a", + &ids(&["duncan"]), + &[], + )); + assert_eq!(records[0].team_id.as_deref(), Some("team-b")); + } + + /// A minimal owner-authored team record for wiring tests. + fn team(id: &str, persona_ids: &[&str]) -> TeamRecord { + TeamRecord { + id: id.to_string(), + name: id.to_string(), + description: None, + instructions: None, + persona_ids: ids(persona_ids), + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + /// Records the injected store IO a commit performs, so a test can assert + /// the wiring saved (or deliberately did not) the agent store. + #[derive(Default)] + struct StoreSpy { + saved: Option>, + } + + /// Metadata-only `update_team` must pass the TRUE prior roster into the + /// delta, so an unchanged roster is an empty delta and no agent write fires. + /// The `&previous_persona_ids` → `&[]` miswire would drop the prior roster, + /// making the whole roster look "added" and re-pointing the unbound instance. + #[test] + fn commit_team_update_uses_true_prior_roster() { + let mut teams = vec![team("team-a", &["duncan"])]; + let existing = vec![instance('a', "duncan", None)]; + let spy = RefCell::new(StoreSpy::default()); + + let updated = commit_team_update( + &mut teams, + "team-a", + "Team A".to_string(), + None, + Some("new instructions".to_string()), + ids(&["duncan"]), + "2026-02-02T00:00:00Z".to_string(), + |_| Ok(()), + || Ok(existing.clone()), + |records| { + spy.borrow_mut().saved = Some(records.to_vec()); + Ok(()) + }, + ) + .expect("metadata-only update succeeds"); + + assert_eq!(updated.instructions.as_deref(), Some("new instructions")); + // Empty delta ⇒ nothing changed ⇒ no save (the true-prior-roster gate). + assert!( + spy.borrow().saved.is_none(), + "metadata-only edit must not write the agent store" + ); + } + + /// Removing a persona from the roster must reach the detach branch through + /// the command wiring: the instance bound to this team is cleared and saved. + #[test] + fn commit_team_update_removal_detaches_through_wiring() { + let mut teams = vec![team("team-a", &["duncan"])]; + let existing = vec![instance('a', "duncan", Some("team-a"))]; + let spy = RefCell::new(StoreSpy::default()); + + commit_team_update( + &mut teams, + "team-a", + "team-a".to_string(), + None, + None, + ids(&[]), + "2026-02-02T00:00:00Z".to_string(), + |_| Ok(()), + || Ok(existing.clone()), + |records| { + spy.borrow_mut().saved = Some(records.to_vec()); + Ok(()) + }, + ) + .expect("removal update succeeds"); + + let saved = spy.borrow().saved.clone().expect("detach must save"); + assert_eq!(saved[0].team_id, None, "removed persona detaches from team"); + } + + /// `create_team` has no prior roster, so its whole roster is the added delta: + /// the unbound instance of a listed persona is bound through the wiring. + #[test] + fn commit_team_create_treats_full_roster_as_added() { + let mut teams: Vec = Vec::new(); + let existing = vec![instance('a', "duncan", None)]; + let spy = RefCell::new(StoreSpy::default()); + + let created = commit_team_create( + &mut teams, + team("team-a", &["duncan"]), + |_| Ok(()), + || Ok(existing.clone()), + |records| { + spy.borrow_mut().saved = Some(records.to_vec()); + Ok(()) + }, + ) + .expect("create succeeds"); + + assert_eq!(created.id, "team-a"); + let saved = spy.borrow().saved.clone().expect("backfill must save"); + assert_eq!( + saved[0].team_id.as_deref(), + Some("team-a"), + "whole roster is the added delta on create" + ); + } + + /// A failing secondary agent write after successful `save_teams` is + /// swallowed: both commits still return the persisted team. Otherwise a UI + /// retry of a create whose team already landed would mint a duplicate. + #[test] + fn commit_returns_ok_when_agent_save_fails() { + let mut teams: Vec = Vec::new(); + let created = commit_team_create( + &mut teams, + team("team-a", &["duncan"]), + |_| Ok(()), + || Ok(vec![instance('a', "duncan", None)]), + |_| Err("disk full".to_string()), + ) + .expect("create swallows secondary-store failure"); + assert_eq!(created.id, "team-a"); + + let mut teams = vec![team("team-a", &["duncan"])]; + let updated = commit_team_update( + &mut teams, + "team-a", + "team-a".to_string(), + None, + None, + ids(&[]), + "2026-02-02T00:00:00Z".to_string(), + |_| Ok(()), + || Err("agent store unreadable".to_string()), + |_| Ok(()), + ) + .expect("update swallows secondary-store failure"); + assert_eq!(updated.persona_ids, Vec::::new()); + } +} + #[tauri::command] pub async fn delete_team(id: String, app: AppHandle) -> Result<(), String> { use tauri::Manager; diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index a32fd2c0e0f..89e6e29cec3 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -233,16 +233,39 @@ pub async fn apply_workspace( // Adopt whatever the pre-scoping release left queued in the global // retention database BEFORE the scoped reconcile and flush run, so // stranded tombstones and archive requests publish on this boot - // instead of being abandoned by the storage cutover. + // instead of being abandoned by the storage cutover. Best-effort: + // it is not a prerequisite for the superseding head — the team leg + // below builds the repaired roster's head fresh from disk with a + // monotonic `created_at` regardless of what the legacy copy left. migrate_legacy_retention_into(&restore_app, &scope); - crate::event_sync::spawn_event_sync( + // Await the reconcile to completion — do NOT spawn it — and + // propagate its failure. The boot migration may have repaired team + // membership on disk; the frontend starts inbound history replay + // the moment `useCommunityInit` observes the applied workspace, and + // an old relay team head could otherwise win that race and overwrite + // the repaired `persona_ids`. The team leg is fatal (see + // `run_event_sync`): only its success durably retains the corrected + // head with a superseding `monotonic_created_at`, so + // `retain_inbound_event`'s equal/older guard rejects the stale head. + // On failure we return `Err` — the command reports failure, + // `useCommunityInit` never exposes the community, and inbound replay + // never starts against an un-superseded disk state. + crate::event_sync::run_event_sync_blocking( restore_app.clone(), scope.owner_keys, scope.db_path, ) + .await?; } Err(error) => { - eprintln!("buzz-desktop: scoped event-sync unavailable after workspace apply: {error}"); + // Scope resolution is a prerequisite for establishing the + // superseding head, so its failure is fatal for the same reason: + // without a scope we cannot retain the repaired roster ahead of an + // inbound replay. Fail the command rather than silently opening the + // inbound lane. + return Err(format!( + "scoped event-sync unavailable after workspace apply: {error}" + )); } } diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index ee8e0d8b108..93990f2b24e 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -13,32 +13,44 @@ use std::path::Path; /// `sync_team_personas` wrote in [`crate::migration::run_boot_migrations`] /// (see its `# Ordering` guard). Event signing needs the resolved owner keys, /// so this runs after identity resolution, not in the boot migrations. -pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: &Path) { +pub fn run_event_sync( + app: &tauri::AppHandle, + owner_keys: &nostr::Keys, + db_path: &Path, +) -> Result<(), String> { + // Persona and agent legs stay best-effort: they log and swallow, and their + // failure does not undo the boot team-membership repair. The team leg is + // fatal — it establishes the superseding local head (a monotonic + // `created_at`) that lets `retain_inbound_event`'s equal/older guard reject + // a stale relay roster. If it fails, the caller must not let the frontend + // expose the community and start inbound replay against an un-superseded + // disk state. migrate_personas_to_events(app, owner_keys, db_path); - migrate_teams_to_events(app, owner_keys, db_path); + migrate_teams_to_events(app, owner_keys, db_path)?; crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys, db_path); + Ok(()) } -/// Spawn the best-effort event reconcile off the synchronous Tauri setup path. +/// Run the scoped event reconcile to completion on the blocking pool. +/// +/// Callers that must not let downstream work observe a not-yet-retained disk +/// state (e.g. `apply_workspace` before the frontend can start inbound history +/// replay) await this so the repaired local heads are durably retained — with a +/// superseding `monotonic_created_at` — before an old relay head can race in. +/// The owner keys are moved in so the task never touches the `AppState::keys` +/// mutex; the reconcile itself is synchronous JSON/SQLite/signing work, so it +/// runs on the blocking pool rather than an async worker. /// -/// The owner keys are cloned before spawning so the task never touches the -/// `AppState::keys` mutex. The reconcile itself is still synchronous JSON, -/// SQLite, and signing work, so it runs on the blocking pool rather than an -/// async worker. -pub fn spawn_event_sync( +/// Returns `Err` if the task fails to join or the fatal team leg errors, so the +/// caller can withhold community exposure until the superseding head is durable. +pub async fn run_event_sync_blocking( app: tauri::AppHandle, owner_keys: nostr::Keys, db_path: std::path::PathBuf, -) { - tauri::async_runtime::spawn(async move { - if let Err(e) = tauri::async_runtime::spawn_blocking(move || { - run_event_sync(&app, &owner_keys, &db_path); - }) +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || run_event_sync(&app, &owner_keys, &db_path)) .await - { - eprintln!("buzz-desktop: event-sync: spawn_blocking failed: {e}"); - } - }); + .map_err(|e| format!("event-sync: spawn_blocking failed: {e}"))? } /// Reconcile `personas.json` into the persona-event retention store. @@ -219,21 +231,23 @@ fn migrate_personas_in_dir_at( /// /// Must run after the persisted identity is resolved (it signs each event with /// the owner's keys). -pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) { +pub fn migrate_teams_to_events( + app: &tauri::AppHandle, + keys: &nostr::Keys, + db_path: &Path, +) -> Result<(), String> { use crate::managed_agents::managed_agents_base_dir; - let Ok(base_dir) = managed_agents_base_dir(app) else { - return; - }; + let base_dir = managed_agents_base_dir(app) + .map_err(|e| format!("team-event-migration: base dir unavailable: {e}"))?; match migrate_teams_in_dir_at(&base_dir, keys, db_path) { - Ok(0) => {} + Ok(0) => Ok(()), Ok(migrated) => { eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention"); + Ok(()) } - Err(e) => { - eprintln!("buzz-desktop: team-event-migration: {e}"); - } + Err(e) => Err(format!("team-event-migration: {e}")), } } diff --git a/desktop/src-tauri/src/event_sync_team_events_tests.rs b/desktop/src-tauri/src/event_sync_team_events_tests.rs index 0f7ab52bf59..b1a56b06616 100644 --- a/desktop/src-tauri/src/event_sync_team_events_tests.rs +++ b/desktop/src-tauri/src/event_sync_team_events_tests.rs @@ -133,3 +133,132 @@ fn migrate_teams_no_file_is_noop() { let keys = nostr::Keys::generate(); assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 0); } + +/// Error-contract for the fatal team leg. `run_event_sync` propagates a team +/// leg failure via `?`, and `apply_workspace` returns that `Err` so the +/// frontend never exposes the community against an un-superseded disk state. +/// This proves the leg genuinely surfaces failure (rather than logging and +/// swallowing) on an unreadable store — the precondition that made the +/// propagation load-bearing. +#[test] +fn migrate_teams_surfaces_error_on_unparseable_store() { + let base = tempfile::tempdir().unwrap(); + std::fs::write(base.path().join("teams.json"), "{ not valid json").unwrap(); + let keys = nostr::Keys::generate(); + assert!(migrate_teams_in_dir(base.path(), &keys).is_err()); +} + +/// Build a signed inbound team head at an explicit `created_at`, mirroring a +/// relay replay of a stale, pre-namespacing roster. +fn stale_inbound_head( + keys: &nostr::Keys, + id: &str, + bare_persona_ids: &[&str], + created_at: i64, +) -> crate::managed_agents::retention::RetainedEvent { + use crate::managed_agents::{team_events::build_team_event, TeamRecord}; + use buzz_core_pkg::kind::KIND_TEAM; + use nostr::JsonUtil; + + let record = TeamRecord { + id: id.to_string(), + name: "Sietch Tabr".to_string(), + description: None, + instructions: None, + persona_ids: bare_persona_ids.iter().map(|s| s.to_string()).collect(), + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + let event = build_team_event(&record) + .unwrap() + .custom_created_at(nostr::Timestamp::from(created_at as u64)) + .sign_with_keys(keys) + .unwrap(); + crate::managed_agents::retention::RetainedEvent { + kind: KIND_TEAM, + pubkey: keys.public_key().to_hex(), + d_tag: id.to_string(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + } +} + +/// Finding-1 retention-precedence guarantee. This proves the *mechanic* the +/// awaited-reconcile ordering relies on — it does not itself exercise +/// `apply_workspace` (an `AppHandle`-level path). Given the boot reconcile has +/// retained the repaired namespaced roster with a monotonic `created_at` +/// (reconcile-first), a stale relay head replayed afterward is older, so +/// `retain_inbound_event` skips it and the repaired roster stays. The +/// inbound-first lane is the counterfactual the ordering closes: with no +/// repaired head retained yet, the very same stale head is applied and restores +/// bare membership. Retention order is the only difference between the lanes; +/// `apply_workspace` awaiting the reconcile (see `commands/workspace.rs`) is +/// what forces the reconcile-first order in production. +#[test] +fn reconcile_first_makes_stale_inbound_team_head_lose() { + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, retain_inbound_event, InboundOutcome, + }; + use buzz_core_pkg::kind::KIND_TEAM; + + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let repaired = serde_json::json!([{ + "id": "sietch-tabr", + "name": "Sietch Tabr", + "persona_ids": ["sietch-tabr:thufir", "sietch-tabr:paul", "sietch-tabr:duncan"], + "is_builtin": false, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }]); + let bare = ["thufir", "paul", "duncan"]; + + // Reconcile-first lane (the fix): the awaited boot reconcile retains the + // repaired namespaced roster with a monotonic `created_at`; a stale relay + // head replayed afterward is older, so `retain_inbound_event` skips it and + // the retained roster stays repaired. + let ordered = tempfile::tempdir().unwrap(); + let ordered_db = ordered.path().join("retention.db"); + write_base_teams(ordered.path(), &repaired); + assert_eq!( + migrate_teams_in_dir_at(ordered.path(), &keys, &ordered_db).unwrap(), + 1 + ); + let conn = open_retention_db(&ordered_db).unwrap(); + let repaired_head = get_retained_event(&conn, KIND_TEAM, &pubkey, "sietch-tabr") + .unwrap() + .unwrap(); + let stale = stale_inbound_head(&keys, "sietch-tabr", &bare, repaired_head.created_at - 1); + assert_eq!( + retain_inbound_event(&conn, &stale).unwrap(), + InboundOutcome::Skipped + ); + let head = get_retained_event(&conn, KIND_TEAM, &pubkey, "sietch-tabr") + .unwrap() + .unwrap(); + assert!(head.content.contains("sietch-tabr:thufir")); + assert!(!head.content.contains("\"thufir\"")); + + // Inbound-first lane (the race the fix closes): with no repaired head + // retained yet, the very same stale relay head is applied, restoring the + // bare pre-namespacing roster. Ordering is the only difference. + let raced = tempfile::tempdir().unwrap(); + let raced_db = raced.path().join("retention.db"); + let raced_conn = open_retention_db(&raced_db).unwrap(); + let stale = stale_inbound_head(&keys, "sietch-tabr", &bare, repaired_head.created_at - 1); + assert_eq!( + retain_inbound_event(&raced_conn, &stale).unwrap(), + InboundOutcome::Applied + ); + let head = get_retained_event(&raced_conn, KIND_TEAM, &pubkey, "sietch-tabr") + .unwrap() + .unwrap(); + assert!(head.content.contains("\"thufir\"")); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c6ccd3709c0..16234aa3d69 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -39,6 +39,7 @@ pub(crate) mod spawn_snapshot; pub(crate) mod storage; pub(crate) mod team_events; mod team_repair; +pub(crate) use team_repair::team_persona_key; mod teams; mod types; diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 0baba6456b9..1e22d7aaeca 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -149,8 +149,7 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { // ensures the dev nest boots with the correct workspace on its first launch, // matching what the prod nest had configured. Skip-if-dest-exists so it is // idempotent and never clobbers a value the dev nest already set explicitly. - // Uses the composed helper so the gate + migration run through the same - // code path that the behavioral test exercises. + // Uses the composed helper so gate + migration share the tested code path. if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) { maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest); } @@ -181,11 +180,12 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { strip_baked_team_instructions(app); refresh_builtin_agent_avatars(app); // B5: manufacture definitions for standalone agents AFTER the fold (so - // pre-existing definition slugs are present for collision checks) and - // before event sync republishes — the backfilled link is what flips the - // 30177 projection to its slim shape. + // pre-existing definition slugs exist for collision checks) and before event + // sync republishes — the backfilled link flips the 30177 projection. backfill_standalone_agents(app); - detach_directory_backed_teams(app); + // Repair dropped team↔member links, then detach directory-backed teams, + // gated on a clean repair so a failure preserves `source_dir` for a retry. + team_membership::repair_then_detach_teams(app); reconcile_provider_mcp_commands(app); reconcile_databricks_v1_to_v2(app); materialize_agent_runtimes(app); @@ -1373,8 +1373,8 @@ use fold::load_persona_runtimes; mod backfill; pub use backfill::backfill_standalone_agents; mod detach; -pub use detach::detach_directory_backed_teams; mod pollen; +mod team_membership; pub(crate) use pollen::*; mod team_suffix; pub use team_suffix::strip_baked_team_instructions; diff --git a/desktop/src-tauri/src/migration/detach.rs b/desktop/src-tauri/src/migration/detach.rs index 9f746e479fc..79123653316 100644 --- a/desktop/src-tauri/src/migration/detach.rs +++ b/desktop/src-tauri/src/migration/detach.rs @@ -9,10 +9,12 @@ use crate::managed_agents::{ManagedAgentRecord, TeamRecord}; /// Lift pack instructions into `TeamRecord.instructions` and detach /// directory-backed teams from their source directories. /// -/// Runs on app launch if any `TeamRecord` still has `source_dir` set. -/// Both output files are written atomically (temp-file + rename), so a crash -/// mid-write leaves the previous version intact and the migration can safely -/// retry on next boot. +/// Core logic, decoupled from the Tauri `AppHandle` for testing. +/// +/// Runs on app launch (gated on a clean team-membership repair) if any +/// `TeamRecord` still has `source_dir` set. Both output files are written +/// atomically (temp-file + rename), so a crash mid-write leaves the previous +/// version intact and the migration can safely retry on next boot. /// /// Steps (written last so the idempotency gate stays open until both files /// are committed): @@ -24,18 +26,6 @@ use crate::managed_agents::{ManagedAgentRecord, TeamRecord}; /// `instructions` if the field is not already set. /// 4. Clear `source_dir`, `is_symlink`, `symlink_target`, `version` on each /// directory-backed `TeamRecord`. -pub fn detach_directory_backed_teams(app: &tauri::AppHandle) { - let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { - return; - }; - match detach_directory_backed_teams_in_dir(&base_dir) { - Ok(0) => {} - Ok(n) => eprintln!("buzz-desktop: detach-dir-teams: detached {n} directory-backed team(s)"), - Err(e) => eprintln!("buzz-desktop: detach-dir-teams: {e}"), - } -} - -/// Core logic, decoupled from the Tauri `AppHandle` for testing. /// /// `base_dir` is the managed-agents base directory (`/agents/`). /// Returns the number of teams detached (0 = nothing to do). diff --git a/desktop/src-tauri/src/migration/team_membership.rs b/desktop/src-tauri/src/migration/team_membership.rs new file mode 100644 index 00000000000..632714f3f91 --- /dev/null +++ b/desktop/src-tauri/src/migration/team_membership.rs @@ -0,0 +1,353 @@ +//! Repair team↔member links that a membership edit failed to propagate. +//! +//! Two independent defects, both rooted in a team-membership change not +//! reaching the records that depend on it, are healed in one pass over +//! `teams.json` + `managed-agents.json`: +//! +//! 1. **Stale `persona_ids`.** Team records written before persona ids were +//! namespaced hold bare slugs (`thufir`) instead of the namespaced id +//! (`sietch-tabr:thufir`). Nothing rewrites them, and the interactive save +//! path (`ensure_persona_ids_are_active`) *drops* an id it cannot resolve — +//! silently shrinking the team. This migration rewrites a stale id to the +//! persona it names whenever that persona is unambiguous, and — unlike the +//! save path — never drops one it cannot resolve. +//! +//! 2. **Orphaned or stale instance `team_id`.** Team instructions are injected +//! at spawn by matching `record.team_id` +//! (`spawn_snapshot::effective_team_instructions`), so an instance's binding +//! must track its persona's membership. Two ways it drifts: adding a persona +//! to a team does not backfill `team_id` on that persona's already-running +//! instances (a member in the roster but not in behavior), and removing a +//! persona while keeping its agents leaves the binding pointing at a team +//! that no longer lists it (still drawing that team's instructions). This +//! backfills an unset binding and heals a stale one — always on the same +//! single-team evidence rule, never guessing across teams. +//! +//! The stale-id rewrite is strictly additive (rewrite-or-leave); the binding +//! repair converges to a fixed point (bound-to-a-listing-team or unbound), so a +//! second boot is a clean no-op either way. Runs BEFORE +//! `detach_directory_backed_teams` so a not-yet-detached directory-backed team +//! can still be scoped by its `source_dir`, and before any UI save can drop an +//! unresolvable id. + +use std::collections::HashMap; +use std::path::Path; + +use crate::managed_agents::{team_persona_key, ManagedAgentRecord, TeamRecord}; + +/// Repair stale team `persona_ids`/instance `team_id`, then detach +/// directory-backed teams — but only when the repair succeeded. +/// +/// `repair` clears no `source_dir`; the downstream detach does. A stale bare +/// slug shared across source teams is disambiguated by `source_dir`, so if +/// repair fails (its backup or write errored) and detach still ran, the next +/// boot would see only ambiguous candidates and the original membership-loss +/// path recurs. Gating detach on a clean repair preserves `source_dir` as retry +/// evidence for that boot; the next boot retries repair and, once clean, +/// detaches. +pub(super) fn repair_then_detach_teams(app: &tauri::AppHandle) { + let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { + return; + }; + orchestrate_repair_then_detach( + || repair_team_membership_in_dir(&base_dir), + || super::detach::detach_directory_backed_teams_in_dir(&base_dir), + ); +} + +/// Gate `detach` on a successful `repair`: run detach only when repair returned +/// `Ok`. Injected ops keep the gate `AppHandle`-free so a failing repair's +/// skip-detach behavior is unit-testable without a filesystem fault. +fn orchestrate_repair_then_detach( + repair: impl FnOnce() -> Result, + detach: impl FnOnce() -> Result, +) { + match repair() { + Ok(repaired) => { + if repaired > 0 { + eprintln!("buzz-desktop: team-membership-repair: repaired {repaired} record(s)"); + } + match detach() { + Ok(0) => {} + Ok(n) => { + eprintln!( + "buzz-desktop: detach-dir-teams: detached {n} directory-backed team(s)" + ) + } + Err(e) => eprintln!("buzz-desktop: detach-dir-teams: {e}"), + } + } + Err(e) => eprintln!( + "buzz-desktop: team-membership-repair: {e} — skipping directory-backed detach this \ + boot to preserve source_dir for a clean-repair retry" + ), + } +} + +/// Core logic, decoupled from the Tauri `AppHandle` for testing. +/// +/// `base_dir` is the managed-agents base directory (`/agents/`). +/// Returns the number of records changed across both files (0 = nothing to do, +/// nothing written, so a re-run is a clean no-op). +pub(super) fn repair_team_membership_in_dir(base_dir: &Path) -> Result { + let teams_path = base_dir.join("teams.json"); + let agents_path = base_dir.join("managed-agents.json"); + + // Definitions and teams both live in these two files; without either there + // is nothing to link. + if !teams_path.exists() || !agents_path.exists() { + return Ok(0); + } + + let teams_content = std::fs::read_to_string(&teams_path) + .map_err(|e| format!("failed to read teams.json: {e}"))?; + let mut teams: Vec = serde_json::from_str(&teams_content) + .map_err(|e| format!("failed to parse teams.json: {e}"))?; + + let agents_content = std::fs::read_to_string(&agents_path) + .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + let mut agents: Vec = serde_json::from_str(&agents_content) + .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + + let rewrites = rewrite_stale_persona_ids(&mut teams, &agents); + let backfills = backfill_instance_team_ids(&teams, &mut agents); + + if rewrites == 0 && backfills == 0 { + return Ok(0); + } + + // Pre-migration backups, both taken BEFORE either live store write: the + // stated contract is a full recovery pair even if a crash lands between the + // two writes, so neither store may be rewritten until both pristine backups + // exist. A stale bare slug shared across source teams is disambiguated by + // `source_dir`, which the downstream detach clears — so the pristine + // pre-repair `teams.json` is the evidence a retry needs. Each backup is + // created once (create-new), so a re-run after a partial failure never + // overwrites the pristine copy with a half-migrated snapshot. + if rewrites > 0 { + let bak = crate::util::resolved_backup_path( + &teams_path, + "teams.json.pre-team-membership-repair.bak", + ); + crate::util::create_restricted_backup_once(&bak, teams_content.as_bytes()) + .map_err(|e| format!("failed to write teams.json backup: {e}"))?; + } + if backfills > 0 { + let bak = crate::util::resolved_backup_path( + &agents_path, + "managed-agents.json.pre-team-membership-repair.bak", + ); + crate::util::create_restricted_backup_once(&bak, agents_content.as_bytes()) + .map_err(|e| format!("failed to write managed-agents.json backup: {e}"))?; + } + + if rewrites > 0 { + let payload = serde_json::to_vec_pretty(&teams) + .map_err(|e| format!("failed to serialize teams.json: {e}"))?; + crate::managed_agents::atomic_write_json(&teams_path, &payload)?; + } + + if backfills > 0 { + // Restricted: this store can carry plaintext agent nsecs on a + // keyringless host (SECURITY.md:90). + let payload = serde_json::to_vec_pretty(&agents) + .map_err(|e| format!("failed to serialize managed-agents.json: {e}"))?; + crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?; + } + + Ok(rewrites + backfills) +} + +/// Set of persona ids that resolve to a definition — the definition records are +/// the key-less unified-store entries (`pubkey == ""`); their `slug` is the id +/// a team references. +fn resolvable_ids(agents: &[ManagedAgentRecord]) -> Vec<&str> { + agents + .iter() + .filter(|r| r.pubkey.is_empty()) + .filter_map(|r| r.slug.as_deref()) + .collect() +} + +/// Rewrite each team's stale `persona_ids` to the persona they name, when +/// unambiguous. Returns the number of ids rewritten. +/// +/// An id is *stale* when no definition slug equals it. Its repair target is the +/// definition whose `source_team_persona_slug` equals the stale id — i.e. the +/// bare slug is the pre-namespacing form of that persona's namespaced slug. The +/// rewrite happens only when exactly one such definition exists (optionally +/// scoped to the team's source team); zero or many candidates leave the id +/// untouched, which is strictly safer than the save path that drops it. +fn rewrite_stale_persona_ids(teams: &mut [TeamRecord], agents: &[ManagedAgentRecord]) -> usize { + let resolvable = resolvable_ids(agents); + let definitions: Vec<&ManagedAgentRecord> = + agents.iter().filter(|r| r.pubkey.is_empty()).collect(); + + let mut rewritten = 0usize; + for team in teams.iter_mut() { + // Scope candidate personas to this team's source team when derivable: + // a directory-backed team keys off its source_dir name; a detached team + // keys off the unique source_team of its already-resolvable members. + let scope = team_source_scope(team, &definitions); + for id in team.persona_ids.iter_mut() { + if resolvable.contains(&id.as_str()) { + continue; + } + let candidates: Vec<&&ManagedAgentRecord> = definitions + .iter() + .filter(|d| d.source_team_persona_slug.as_deref() == Some(id.as_str())) + .filter(|d| match scope.as_deref() { + Some(team_key) => d.source_team.as_deref() == Some(team_key), + None => true, + }) + .collect(); + let [only] = candidates.as_slice() else { + eprintln!( + "buzz-desktop: team-membership-repair: team {:?}: leaving unresolvable \ + persona id {:?} ({} candidate(s))", + team.id, + id, + candidates.len() + ); + continue; + }; + if let Some(slug) = only.slug.as_deref() { + *id = slug.to_string(); + rewritten += 1; + } + } + } + rewritten +} + +/// The source-team key that scopes a team's persona candidates, or `None` when +/// it cannot be derived (matching then falls back to a global unique slug). +/// +/// Directory-backed teams use `team_persona_key` (the pack manifest id). A +/// detached team (`source_dir` cleared) has no such key, so we infer it from +/// the unique `source_team` among its members that already resolve. +fn team_source_scope(team: &TeamRecord, definitions: &[&ManagedAgentRecord]) -> Option { + if team.source_dir.is_some() { + return Some(team_persona_key(team).to_string()); + } + let mut source_teams: Vec<&str> = team + .persona_ids + .iter() + .filter_map(|id| { + definitions + .iter() + .find(|d| d.slug.as_deref() == Some(id.as_str())) + .and_then(|d| d.source_team.as_deref()) + }) + .collect(); + source_teams.sort_unstable(); + source_teams.dedup(); + match source_teams.as_slice() { + [only] => Some((*only).to_string()), + _ => None, + } +} + +/// Repair instance `team_id` against the current rosters. Returns the number of +/// instances changed. +/// +/// Two directions, both conservative and evidence-gated: +/// +/// - **Unbound → bound (backfill).** An instance whose persona is a team member +/// but whose own `team_id` is unset is bound to that team, so it spawns with +/// the team's instructions. Only when the persona belongs to *exactly one* +/// team — a persona spanning several teams has no evidence selecting one +/// (JSON team order is not ownership), so it is left unbound and logged. +/// - **Stale binding → cleared or re-pointed.** An instance bound to a team +/// whose roster no longer lists its persona (a "keep agents" removal left the +/// binding behind, so the kept instance keeps drawing that team's +/// instructions at spawn) is healed: re-pointed when the persona now belongs +/// to exactly one *other* team (same single-evidence rule), otherwise unbound +/// and logged. A binding whose team still lists the persona is authoritative +/// and never touched. +/// +/// Idempotent: after a repair every instance is either bound to a team that +/// lists it or unbound with no single-team evidence, so a second pass is a +/// no-op. +fn backfill_instance_team_ids(teams: &[TeamRecord], agents: &mut [ManagedAgentRecord]) -> usize { + // persona_id → the sole team referencing it, or None once a *distinct* + // second team is seen (ambiguous → never used as binding evidence). A + // persona listed twice within one team is not ambiguity — duplicates are + // not prohibited at the storage boundary (`ensure_persona_ids_are_active` + // checks existence only; create/update/inbound persist the vector + // unchanged), so poisoning on a same-team repeat would strand a + // legitimately single-team instance. + let mut persona_to_team: HashMap<&str, Option<&str>> = HashMap::new(); + // Team ids that exist in the store, and the (team_id, persona_id) pairs they + // list. A binding is *stale* only when its team still exists but no longer + // lists the persona — a binding to an absent team is left alone (it already + // degrades to no instructions via `effective_team_instructions`, and a + // deleted team is not this repair's concern). + let mut team_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut membership: std::collections::HashSet<(&str, &str)> = std::collections::HashSet::new(); + for team in teams { + team_ids.insert(team.id.as_str()); + for persona_id in &team.persona_ids { + membership.insert((team.id.as_str(), persona_id.as_str())); + persona_to_team + .entry(persona_id.as_str()) + .and_modify(|slot| { + if slot.is_some_and(|seen| seen != team.id.as_str()) { + *slot = None; + } + }) + .or_insert(Some(team.id.as_str())); + } + } + + let mut repaired = 0usize; + for agent in agents.iter_mut() { + if agent.pubkey.is_empty() { + continue; + } + let Some(persona_id) = agent.persona_id.as_deref() else { + continue; + }; + match agent.team_id.as_deref() { + // Live binding, or a binding to an absent team: leave it. A binding + // is only stale when its team exists and dropped the persona. + Some(bound) + if !team_ids.contains(bound) || membership.contains(&(bound, persona_id)) => {} + // Stale binding: the still-present bound team dropped this persona. + // Re-point on single-team evidence, else unbind — never guess. + Some(_) => match persona_to_team.get(persona_id) { + Some(Some(team_id)) => { + agent.team_id = Some((*team_id).to_string()); + repaired += 1; + } + _ => { + eprintln!( + "buzz-desktop: team-membership-repair: unbinding instance {:?} — persona \ + {persona_id:?} left its team's roster with no single-team successor", + agent.pubkey + ); + agent.team_id = None; + repaired += 1; + } + }, + // Unbound: backfill on single-team evidence. + None => match persona_to_team.get(persona_id) { + Some(Some(team_id)) => { + agent.team_id = Some((*team_id).to_string()); + repaired += 1; + } + Some(None) => eprintln!( + "buzz-desktop: team-membership-repair: leaving instance {:?} unbound — persona \ + {persona_id:?} spans multiple teams", + agent.pubkey + ), + None => {} + }, + } + } + repaired +} + +#[cfg(test)] +#[path = "team_membership_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/migration/team_membership_tests.rs b/desktop/src-tauri/src/migration/team_membership_tests.rs new file mode 100644 index 00000000000..d284d56423b --- /dev/null +++ b/desktop/src-tauri/src/migration/team_membership_tests.rs @@ -0,0 +1,625 @@ +use super::repair_team_membership_in_dir; +use crate::migration::test_support::{ + read_agents_json, read_teams_json, write_agents_json, write_teams_json, +}; +use std::path::{Path, PathBuf}; + +fn base(dir: &Path) -> PathBuf { + dir.join("agents") +} + +/// A key-less definition record: `pubkey == ""`, persona id == `slug`. +/// `source_team` is the manifest id; `source_team_persona_slug` is the +/// pre-namespacing bare slug a stale team id would carry. +fn definition(slug: &str, source_team: &str, bare_slug: &str) -> serde_json::Value { + serde_json::json!({ + "name": slug, + "pubkey": "", + "relay_url": "ws://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "parallelism": 4, + "system_prompt": "prompt", + "model": "gpt-x", + "provider": "openai", + "env_vars": {}, + "start_on_app_launch": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "slug": slug, + "source_team": source_team, + "source_team_persona_slug": bare_slug, + }) +} + +/// A standalone definition with no team provenance (persona id == slug). +fn standalone_definition(slug: &str) -> serde_json::Value { + serde_json::json!({ + "name": slug, + "pubkey": "", + "relay_url": "ws://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "parallelism": 4, + "system_prompt": "prompt", + "model": "gpt-x", + "provider": "openai", + "env_vars": {}, + "start_on_app_launch": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "slug": slug, + }) +} + +/// A running instance record: `pubkey` set, linked to a persona by `persona_id`. +fn instance(pubkey_seed: char, persona_id: &str, team_id: Option<&str>) -> serde_json::Value { + let mut record = serde_json::json!({ + "name": persona_id, + "pubkey": pubkey_seed.to_string().repeat(64), + "relay_url": "ws://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "parallelism": 4, + "system_prompt": "prompt", + "model": "gpt-x", + "provider": "openai", + "env_vars": {}, + "start_on_app_launch": true, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "persona_id": persona_id, + }); + record["team_id"] = match team_id { + Some(id) => serde_json::json!(id), + None => serde_json::Value::Null, + }; + record +} + +fn team(id: &str, persona_ids: &[&str]) -> serde_json::Value { + serde_json::json!({ + "id": id, + "name": "Sietch Tabr", + "description": null, + "persona_ids": persona_ids, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + }) +} + +fn team_persona_ids(dir: &Path, id: &str) -> Vec { + read_teams_json(dir) + .into_iter() + .find(|t| t["id"] == id) + .unwrap()["persona_ids"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect() +} + +fn instance_team_id(dir: &Path, pubkey_seed: char) -> Option { + read_agents_json(dir) + .into_iter() + .find(|r| r["pubkey"].as_str() == Some(&pubkey_seed.to_string().repeat(64))) + .unwrap()["team_id"] + .as_str() + .map(str::to_string) +} + +const TEAM_ID: &str = "ab5c038c-1b12-46e2-8283-d6f7c0606fce"; +const ST: &str = "com.wpfleger.sietch-tabr"; + +/// Will's pre-fix store: the team holds four bare pre-namespacing ids plus one +/// resolvable standalone id. Each bare id names exactly one team persona, so +/// all four are rewritten to their namespaced slug and the standalone id is +/// left untouched — the class the save path silently drops. +#[test] +fn rewrites_bare_ids_to_namespaced_slugs() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json( + dir.path(), + &serde_json::json!([team( + TEAM_ID, + &["369695d6", "thufir", "paul", "duncan", "alia"] + )]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + standalone_definition("369695d6"), + definition("sietch-tabr:thufir", ST, "thufir"), + definition("sietch-tabr:paul", ST, "paul"), + definition("sietch-tabr:duncan", ST, "duncan"), + definition("sietch-tabr:alia", ST, "alia"), + ]), + ); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 4); + assert_eq!( + team_persona_ids(dir.path(), TEAM_ID), + vec![ + "369695d6", + "sietch-tabr:thufir", + "sietch-tabr:paul", + "sietch-tabr:duncan", + "sietch-tabr:alia", + ] + ); +} + +/// A directory-backed team scopes candidates by its `source_dir` name (the pack +/// manifest id), so a bare slug that appears under two different source teams is +/// disambiguated to the one this team is sourced from. +#[test] +fn scopes_candidates_by_source_dir_for_directory_backed_team() { + let dir = tempfile::tempdir().unwrap(); + let mut t = team(TEAM_ID, &["thufir"]); + t["source_dir"] = serde_json::json!(format!("/packs/{ST}")); + write_teams_json(dir.path(), &serde_json::json!([t])); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:thufir", ST, "thufir"), + // A collision: a different team also has a persona whose bare slug + // is "thufir". Without source scoping this would be ambiguous. + definition("other:thufir", "com.other.pack", "thufir"), + ]), + ); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1); + assert_eq!( + team_persona_ids(dir.path(), TEAM_ID), + vec!["sietch-tabr:thufir"] + ); +} + +/// A bare id that names two personas with no usable scope is ambiguous: the +/// migration leaves it in place (strictly safer than the save path, which drops +/// it) and the file is not rewritten. +#[test] +fn leaves_ambiguous_id_in_place_without_writing() { + let dir = tempfile::tempdir().unwrap(); + // Detached team (no source_dir) with a single stale member => no resolvable + // sibling to infer a source-team scope from. + write_teams_json(dir.path(), &serde_json::json!([team(TEAM_ID, &["thufir"])])); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:thufir", ST, "thufir"), + definition("other:thufir", "com.other.pack", "thufir"), + ]), + ); + let before = std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap(); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 0); + assert_eq!(team_persona_ids(dir.path(), TEAM_ID), vec!["thufir"]); + assert_eq!( + std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap(), + before, + "an ambiguous-only store is never rewritten" + ); + assert!( + !base(dir.path()) + .join("teams.json.pre-team-membership-repair.bak") + .exists(), + "no backup when nothing is repaired" + ); +} + +/// A detached team infers its source-team scope from the unique `source_team` +/// among its already-resolvable members, so a bare id is disambiguated even +/// without a `source_dir`. +#[test] +fn infers_scope_from_resolvable_siblings_when_detached() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json( + dir.path(), + &serde_json::json!([team(TEAM_ID, &["sietch-tabr:paul", "thufir"])]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:paul", ST, "paul"), + definition("sietch-tabr:thufir", ST, "thufir"), + definition("other:thufir", "com.other.pack", "thufir"), + ]), + ); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1); + assert_eq!( + team_persona_ids(dir.path(), TEAM_ID), + vec!["sietch-tabr:paul", "sietch-tabr:thufir"] + ); +} + +/// Backfill sets `team_id` on an instance whose persona is a team member but +/// whose own `team_id` is null (the Gurney case), and leaves an already-bound +/// instance untouched (a persona shared across teams keeps its binding). +#[test] +fn backfills_null_team_id_but_never_re_points_a_bound_instance() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json( + dir.path(), + &serde_json::json!([team(TEAM_ID, &["sietch-tabr:gurney", "sietch-tabr:hayt"])]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:gurney", ST, "gurney"), + definition("sietch-tabr:hayt", ST, "hayt"), + instance('g', "sietch-tabr:gurney", None), + instance('h', "sietch-tabr:hayt", Some("other-team")), + ]), + ); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1); + assert_eq!(instance_team_id(dir.path(), 'g').as_deref(), Some(TEAM_ID)); + assert_eq!( + instance_team_id(dir.path(), 'h').as_deref(), + Some("other-team"), + "an already-bound instance is never re-pointed" + ); +} + +/// A legacy unbound instance whose persona belongs to *two* teams is left +/// unbound: JSON team order is not ownership evidence, and the product permits +/// one persona under multiple teams with distinct instructions. Its team +/// sibling — a persona in only one team — is still backfilled in the same pass. +#[test] +fn leaves_unbound_instance_of_a_multi_team_persona_unbound() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json( + dir.path(), + &serde_json::json!([ + team(TEAM_ID, &["sietch-tabr:duncan", "sietch-tabr:paul"]), + team("other-team", &["sietch-tabr:duncan"]), + ]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:duncan", ST, "duncan"), + definition("sietch-tabr:paul", ST, "paul"), + instance('d', "sietch-tabr:duncan", None), + instance('p', "sietch-tabr:paul", None), + ]), + ); + + // Only Paul (single-team) is backfilled; Duncan (two teams) stays unbound. + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1); + assert_eq!(instance_team_id(dir.path(), 'd'), None); + assert_eq!(instance_team_id(dir.path(), 'p').as_deref(), Some(TEAM_ID)); +} + +/// A persona listed twice within a *single* team is not ambiguity — the storage +/// boundary does not dedupe `persona_ids`. Its unbound instance is still bound +/// to that one team; only a *distinct* second team poisons the entry. +#[test] +fn same_team_duplicate_persona_id_still_backfills() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json( + dir.path(), + &serde_json::json!([team(TEAM_ID, &["sietch-tabr:duncan", "sietch-tabr:duncan"])]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:duncan", ST, "duncan"), + instance('d', "sietch-tabr:duncan", None), + ]), + ); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1); + assert_eq!(instance_team_id(dir.path(), 'd').as_deref(), Some(TEAM_ID)); +} + +/// A stale binding — the bound team no longer lists the instance's persona (a +/// "keep agents" removal left it behind) — is cleared when no other single team +/// claims the persona, so the kept instance stops drawing that team's +/// instructions at spawn. +#[test] +fn clears_stale_binding_when_persona_left_its_team() { + let dir = tempfile::tempdir().unwrap(); + // The team no longer lists gurney; the instance is still bound to it. + write_teams_json( + dir.path(), + &serde_json::json!([team(TEAM_ID, &["sietch-tabr:paul"])]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:gurney", ST, "gurney"), + definition("sietch-tabr:paul", ST, "paul"), + instance('g', "sietch-tabr:gurney", Some(TEAM_ID)), + ]), + ); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1); + assert_eq!(instance_team_id(dir.path(), 'g'), None); +} + +/// A stale binding is *re-pointed* — not merely cleared — when the persona now +/// belongs to exactly one other team, matching the single-evidence backfill +/// rule. +#[test] +fn repoints_stale_binding_to_the_sole_successor_team() { + let dir = tempfile::tempdir().unwrap(); + // gurney left TEAM_ID but is the sole member of other-team. + write_teams_json( + dir.path(), + &serde_json::json!([ + team(TEAM_ID, &["sietch-tabr:paul"]), + team("other-team", &["sietch-tabr:gurney"]), + ]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:gurney", ST, "gurney"), + definition("sietch-tabr:paul", ST, "paul"), + instance('g', "sietch-tabr:gurney", Some(TEAM_ID)), + ]), + ); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1); + assert_eq!( + instance_team_id(dir.path(), 'g').as_deref(), + Some("other-team") + ); +} + +/// A binding whose team still lists the persona is authoritative — a repair pass +/// leaves it untouched even when that persona also belongs to another team. +#[test] +fn leaves_live_binding_untouched_for_multi_team_persona() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json( + dir.path(), + &serde_json::json!([ + team(TEAM_ID, &["sietch-tabr:duncan"]), + team("other-team", &["sietch-tabr:duncan"]), + ]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:duncan", ST, "duncan"), + instance('d', "sietch-tabr:duncan", Some(TEAM_ID)), + ]), + ); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 0); + assert_eq!(instance_team_id(dir.path(), 'd').as_deref(), Some(TEAM_ID)); +} + +/// A store that needs no repair is a clean no-op: `Ok(0)`, no write, no backup. +#[test] +fn clean_store_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json( + dir.path(), + &serde_json::json!([team(TEAM_ID, &["sietch-tabr:paul"])]), + ); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:paul", ST, "paul"), + instance('p', "sietch-tabr:paul", Some(TEAM_ID)), + ]), + ); + let teams_before = std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap(); + let agents_before = + std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 0); + assert_eq!( + std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap(), + teams_before + ); + assert_eq!( + std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(), + agents_before + ); +} + +/// The full repair is idempotent: a second boot over the already-repaired store +/// finds nothing to do and does not write. +#[test] +fn second_run_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json(dir.path(), &serde_json::json!([team(TEAM_ID, &["thufir"])])); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:thufir", ST, "thufir"), + instance('t', "sietch-tabr:thufir", None), + ]), + ); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 2); + let teams_after = std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap(); + let agents_after = + std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); + + assert_eq!( + repair_team_membership_in_dir(&base(dir.path())).unwrap(), + 0, + "second run finds nothing" + ); + assert_eq!( + std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap(), + teams_after + ); + assert_eq!( + std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(), + agents_after + ); +} + +#[test] +fn missing_store_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(base(dir.path())).unwrap(); + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 0); +} + +#[test] +fn unparseable_store_errors_without_writing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(base(dir.path())).unwrap(); + let teams_path = base(dir.path()).join("teams.json"); + std::fs::write(&teams_path, "{ not json").unwrap(); + write_agents_json(dir.path(), &serde_json::json!([])); + + let err = repair_team_membership_in_dir(&base(dir.path())).unwrap_err(); + assert!(err.contains("failed to parse"), "unexpected error: {err}"); + assert_eq!( + std::fs::read_to_string(&teams_path).unwrap(), + "{ not json", + "a corrupt store is left for manual recovery" + ); +} + +/// The teams.json backup captures the pre-migration bytes and is written once. +#[test] +fn writes_teams_backup_once() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json(dir.path(), &serde_json::json!([team(TEAM_ID, &["thufir"])])); + write_agents_json( + dir.path(), + &serde_json::json!([definition("sietch-tabr:thufir", ST, "thufir")]), + ); + let bak = base(dir.path()).join("teams.json.pre-team-membership-repair.bak"); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1); + let bak_content = std::fs::read_to_string(&bak).unwrap(); + assert!( + bak_content.contains("\"thufir\""), + "backup holds the pre-migration stale id" + ); +} + +/// Both pristine backups are created BEFORE either live store is rewritten, so +/// a crash between the two writes still leaves a full recovery pair (Carl's +/// backup-contract finding). A stale bare slug on the team (drives the teams +/// rewrite) plus an unbound instance (drives the agents backfill) exercises +/// both stores; each backup must hold the pre-migration bytes. +#[test] +fn both_backups_precede_either_live_write() { + let dir = tempfile::tempdir().unwrap(); + write_teams_json(dir.path(), &serde_json::json!([team(TEAM_ID, &["thufir"])])); + write_agents_json( + dir.path(), + &serde_json::json!([ + definition("sietch-tabr:thufir", ST, "thufir"), + instance('t', "sietch-tabr:thufir", None), + ]), + ); + let teams_bak = base(dir.path()).join("teams.json.pre-team-membership-repair.bak"); + let agents_bak = base(dir.path()).join("managed-agents.json.pre-team-membership-repair.bak"); + + assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 2); + + // teams.json backup holds the stale bare slug (pre-rewrite bytes). + let teams_bak_content = std::fs::read_to_string(&teams_bak).unwrap(); + assert!( + teams_bak_content.contains("\"thufir\"") + && !teams_bak_content.contains("sietch-tabr:thufir"), + "teams backup captures pre-rewrite bytes" + ); + // managed-agents.json backup holds the null binding (pre-backfill bytes). + let agents_bak_content = std::fs::read_to_string(&agents_bak).unwrap(); + assert!( + agents_bak_content.contains("\"team_id\": null"), + "agents backup captures pre-backfill bytes" + ); +} + +// ── repair→detach orchestration gate (Carl's finding #2) ────────────────── + +use super::orchestrate_repair_then_detach; +use std::cell::Cell; + +/// A failed repair must SKIP the directory-backed detach: detach clears +/// `source_dir`, the disambiguating evidence a clean-repair retry needs, so +/// running it after a repair error would let the original membership-loss path +/// recur on the next boot. +#[test] +fn failed_repair_skips_detach() { + let detach_ran = Cell::new(false); + orchestrate_repair_then_detach( + || Err("repair write failed".to_string()), + || { + detach_ran.set(true); + Ok(0) + }, + ); + assert!( + !detach_ran.get(), + "detach must not run when repair failed — source_dir is preserved for retry" + ); +} + +/// A successful repair runs detach, whether or not the repair changed anything +/// (a clean-store boot with directory-backed teams still needs detaching). +#[test] +fn successful_repair_runs_detach() { + let detach_ran = Cell::new(false); + orchestrate_repair_then_detach( + || Ok(0), + || { + detach_ran.set(true); + Ok(1) + }, + ); + assert!( + detach_ran.get(), + "detach runs after a clean repair even when repair changed nothing" + ); +} + +/// End-to-end discriminating proof: a failed repair must leave a +/// directory-backed team's `source_dir` intact, because the gate skips the real +/// detach op that would otherwise clear it. The store here is fully valid — so +/// detach WOULD succeed and strip `source_dir` if the gate let it run — which +/// is what makes this catch a gate that runs detach unconditionally. +#[test] +fn failed_repair_preserves_source_dir_against_real_detach() { + let dir = tempfile::tempdir().unwrap(); + let base_dir = base(dir.path()); + let mut t = team(TEAM_ID, &["sietch-tabr:thufir"]); + t["source_dir"] = serde_json::json!(format!("/packs/{ST}")); + write_teams_json(dir.path(), &serde_json::json!([t])); + write_agents_json( + dir.path(), + &serde_json::json!([definition("sietch-tabr:thufir", ST, "thufir")]), + ); + + orchestrate_repair_then_detach( + || Err("repair backup write failed".to_string()), + || super::super::detach::detach_directory_backed_teams_in_dir(&base_dir), + ); + + let source_dir = read_teams_json(dir.path()) + .into_iter() + .find(|t| t["id"] == TEAM_ID) + .unwrap()["source_dir"] + .clone(); + assert_eq!( + source_dir, + serde_json::json!(format!("/packs/{ST}")), + "a failed repair must preserve source_dir — detach never ran to clear it" + ); +} diff --git a/desktop/src-tauri/src/migration_test_support.rs b/desktop/src-tauri/src/migration_test_support.rs index 64a428949ba..b68415c6b5f 100644 --- a/desktop/src-tauri/src/migration_test_support.rs +++ b/desktop/src-tauri/src/migration_test_support.rs @@ -29,3 +29,17 @@ pub(crate) fn read_personas_json(dir: &Path) -> Vec { let content = std::fs::read_to_string(dir.join("agents/personas.json")).unwrap(); serde_json::from_str(&content).unwrap() } + +pub(crate) fn write_teams_json(dir: &Path, records: &serde_json::Value) { + std::fs::create_dir_all(dir.join("agents")).unwrap(); + std::fs::write( + dir.join("agents/teams.json"), + serde_json::to_vec_pretty(records).unwrap(), + ) + .unwrap(); +} + +pub(crate) fn read_teams_json(dir: &Path) -> Vec { + let content = std::fs::read_to_string(dir.join("agents/teams.json")).unwrap(); + serde_json::from_str(&content).unwrap() +} From 439c03749182495ee09f85a73423dd17e7ccda61 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 15:49:31 -0600 Subject: [PATCH 11/16] fix(desktop): align preview sidebar row styling (#6163) ## Summary - apply the inactive primary-navigation opacity treatment to every sidebar destination, including Pulse, Projects, and Workflows - remove the duplicated Inbox and Agents conditionals so future gated rows inherit the same hierarchy - add E2E coverage for all inactive rows and restoration to full opacity when selected ## Validation - `pnpm --dir desktop build:e2e` - `pnpm --dir desktop exec playwright test badge.spec.ts --grep "primary navigation rows share the same inactive emphasis" --project=smoke` - pre-push hook: desktop check, typecheck, and 4,984 unit tests Signed-off-by: Wes Co-authored-by: Carl --- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 26 +++----------- .../src/shared/styles/globals/components.css | 9 +++++ desktop/tests/e2e/badge.spec.ts | 34 +++++++++++++++++++ 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 4a618fcf0a4..3631af15c95 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -101,7 +101,7 @@ export function AppSidebarPrimaryMenu({ data-tauri-drag-region data-testid="sidebar-primary-menu" > - + - - - Inbox - + + Inbox {homeBadgeCount > 0 ? ( - - - Agents - + + Agents diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css index 172c1b180c2..f206539a937 100644 --- a/desktop/src/shared/styles/globals/components.css +++ b/desktop/src/shared/styles/globals/components.css @@ -1,4 +1,13 @@ @layer components { + /* Primary navigation rows share one inactive hierarchy, including preview + * features that can appear between the permanent destinations. Keeping the + * treatment on the menu prevents newly gated rows from silently rendering + * at stronger emphasis than Inbox and Agents. */ + .sidebar-primary-menu [data-sidebar="menu-button"]:not([data-active="true"]) + > :is(svg, [data-sidebar="menu-label"]) { + opacity: 0.8; + } + .buzz-side-panel-enter { animation: buzz-side-panel-enter 260ms cubic-bezier(0.32, 0.72, 0, 1) both; transform-origin: right center; diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 98bb2dadef9..f1b618b0d64 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -105,6 +105,40 @@ test("selected Inbox and Agents rows keep their highlight without bold text", as await expect(agents).toHaveCSS("font-weight", "400"); }); +test("primary navigation rows share the same inactive emphasis", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const primaryMenu = page.getByTestId("sidebar-primary-menu"); + const inactiveRows = [ + primaryMenu.getByRole("button", { name: "Inbox", exact: true }), + page.getByTestId("open-pulse-view"), + page.getByTestId("open-projects-view"), + page.getByTestId("open-agents-view"), + page.getByTestId("open-workflows-view"), + ]; + + for (const row of inactiveRows) { + await expect(row).toHaveAttribute("data-active", "false"); + await expect(row.locator("[data-sidebar=menu-label]")).toHaveCSS( + "opacity", + "0.8", + ); + await expect(row.locator("svg")).toHaveCSS("opacity", "0.8"); + } + + const pulse = page.getByTestId("open-pulse-view"); + await pulse.click(); + await expect(pulse).toHaveAttribute("data-active", "true"); + await expect(pulse.locator("[data-sidebar=menu-label]")).toHaveCSS( + "opacity", + "1", + ); + await expect(pulse.locator("svg")).toHaveCSS("opacity", "1"); +}); + test("hovering a channel keeps its text color", async ({ page }) => { await page.goto("/"); const channel = page.getByTestId("channel-engineering"); From f64899e5d17df4c928ea415a5f42052120edaecb Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Mon, 17 Aug 2026 18:11:27 -0400 Subject: [PATCH 12/16] Remove Startup Recovery section in base prompt (#6161) ## Why Managed channel sessions already receive authoritative per-turn context. The old startup recovery checklist told every new session to scan the global feed ## What - Remove `Startup Recovery` with concise channel and heartbeat turn contracts. ## Risk Assessment Low. This changes prompt guidance and its test only; routing and runtime behavior are unchanged. Generated with Codex --------- Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/base_prompt.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 83b9357171a..a746e217628 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -87,13 +87,6 @@ All replies and delegations — including task assignments to other agents — g - Use top-level channel-visible posts for milestones teammates must act on: picked up, blocked + need input, PR up, done. - Praise in public; correct in the work, not the person. -## Startup Recovery - -1. `buzz feed get` — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`. -2. `buzz messages get --channel ` on assigned channels — catch up on recent history. -3. Check `AGENTS.md` in your working directory for team context. -4. Check `RESEARCH/`, `GUIDES/`, `PLANS/` before searching externally. Use `buzz messages search --query "..."` for cross-channel keyword lookups. - ## Workspace Layout Your persistent workspace is in your working directory: From f7a01bda7b1bf95cdbc9dc21bb69970955b14ecc Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 17 Aug 2026 15:16:14 -0700 Subject: [PATCH 13/16] fix(workflows): preserve multi-channel listing semantics (#6009) **Category:** fix **User Impact:** Workflow listings reliably include every accessible channel, including for users with more than 128 memberships and when connected to older relays. **Problem:** Multi-value `#h` filters could lose live delivery, apply channel scoping after SQL limits, mishandle partial authorization or revocation, and permit unbounded membership work. Desktop also submitted every channel in one request, exceeding the relay's new 128-value safety bound. **Solution:** Preserve NIP-01 OR semantics across relay query, count, and live-subscription paths while enforcing authorization and bounded explicit-channel work before database or Redis operations. Desktop keeps the older-relay-compatible one-channel-per-filter shape, sends filters in bounded batches, combines responses, and deduplicates signed events by event ID.
File changes **crates/buzz-db/src/event.rs** Distinguishes authorization channel scopes from explicit `#h` scopes in list and count SQL so requested channels are applied before limits without implicitly including global rows. **crates/buzz-relay/src/handlers/req.rs** Shares explicit-channel scope extraction and limits, preserves valid OR siblings when malformed branches cannot match, repairs request-local membership misses, and registers authorized live subscriptions per channel. **crates/buzz-relay/src/handlers/count.rs** Applies the same bounded explicit-channel authorization to COUNT and preserves channel scope when a multi-channel request narrows to one authorized channel. **crates/buzz-relay/src/api/bridge.rs** Brings HTTP query and count behavior in line with WebSocket semantics before SQL execution and rejects over-limit explicit-channel requests before membership I/O. **crates/buzz-relay/src/subscription.rs** Indexes multi-channel subscriptions by every authorized channel and shrinks, rather than destroys, their scope when one channel is revoked. **crates/buzz-relay/src/handlers/side_effects.rs** Releases only revoked channel topics and sends terminal closure only when no authorized channel remains. **crates/buzz-test-client/tests/e2e_relay.rs** Adds ignored relay integration coverage for multi-channel delivery and valid historical/live behavior with malformed or empty OR siblings. **desktop/src-tauri/src/commands/workflows.rs** Builds one single-channel filter per membership, submits at most 128 per relay request, combines batches, and deduplicates by immutable signed event ID. **desktop/src-tauri/src/commands/workflows_tests.rs** Covers filter compatibility, malformed input, 129-channel batching, and cross-batch event-ID deduplication.
## Reproduction steps 1. Join multiple channels containing workflows, open **Workflows**, and confirm workflows from every accessible channel appear. 2. Repeat with more than 128 memberships and confirm the listing remains complete rather than failing the relay request. 3. Send a multi-value `#h` query/count and confirm only requested authorized channels affect SQL limits and counts. 4. Subscribe to channels A and B, revoke A, and confirm B continues delivering live events. 5. Subscribe with a valid channel branch plus a malformed or empty `#h` sibling and confirm valid history, EOSE, and post-EOSE live delivery still occur. ## Validation At pushed head `c419a923f05e483ab26c006a0b3a80cfb3c73844`: - Relay request tests: 53 passed. - Desktop full Rust unit suite: 2,468 passed, 17 ignored. - Relay E2E target compiled with `--no-run`. - Strict relay clippy passed. - Desktop Tauri clippy/check passed. - Pre-push Rust tests and Desktop Tauri checks passed. - Rust formatting and `git diff --check` passed. --------- Signed-off-by: Taylor Ho --- crates/buzz-core/src/filter.rs | 22 + crates/buzz-db/src/event.rs | 120 ++++- crates/buzz-relay/src/api/bridge.rs | 82 ++- crates/buzz-relay/src/connection.rs | 24 +- crates/buzz-relay/src/handlers/close.rs | 24 +- crates/buzz-relay/src/handlers/count.rs | 109 ++-- crates/buzz-relay/src/handlers/req.rs | 476 ++++++++++++++---- .../buzz-relay/src/handlers/side_effects.rs | 27 +- crates/buzz-relay/src/subscription.rs | 363 +++++++++---- crates/buzz-test-client/tests/e2e_relay.rs | 80 ++- desktop/src-tauri/src/commands/workflows.rs | 75 ++- .../src-tauri/src/commands/workflows_tests.rs | 75 +++ 12 files changed, 1170 insertions(+), 307 deletions(-) diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 1671f76224f..32e3a7ad16b 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -184,6 +184,28 @@ mod tests { )); } + #[test] + fn h_tag_multi_value_filter_matches_any_channel() { + let channel_a = uuid::Uuid::new_v4(); + let channel_b = uuid::Uuid::new_v4(); + let stored = stored_with_tag(Tag::parse(["h", &channel_b.to_string()]).unwrap()); + let filter = Filter::new().custom_tags( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + [channel_a.to_string(), channel_b.to_string()], + ); + + assert!(filters_match(&[filter], &stored)); + } + + #[test] + fn empty_h_tag_filter_matches_nothing() { + let channel_id = uuid::Uuid::new_v4(); + let stored = stored_with_tag(Tag::parse(["h", &channel_id.to_string()]).unwrap()); + let filter: Filter = serde_json::from_value(serde_json::json!({ "#h": [] })).unwrap(); + + assert!(!filters_match(&[filter], &stored)); + } + #[test] fn h_tag_fallback_uses_stored_channel_id() { // Reactions (kind:7) and deletions (kind:5) don't carry h-tags — diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index db150571719..5d682d7843a 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -70,10 +70,17 @@ pub struct EventQuery { /// Restrict results to events with an `e` tag referencing any of these event IDs (hex). /// Uses JSONB containment (`tags @> ...`) against the `tags` column. pub e_tags: Option>, - /// Restrict results to events in any of these channels, while retaining - /// channel-less global events. Applied before SQL `LIMIT` so access-filtered - /// historical pages have exact exhaustion semantics. + /// Restrict results to events in any of these channels. By default, + /// channel-less global events are retained so this can enforce a viewer's + /// accessible-channel scope without hiding global events. Set + /// [`EventQuery::channel_ids_include_global`] to `false` for an explicit + /// multi-channel `#h` filter, which must match only requested channels. + /// Applied before SQL `LIMIT` so access- and filter-scoped historical pages + /// have exact exhaustion semantics. pub channel_ids: Option>, + /// Whether [`EventQuery::channel_ids`] also retains channel-less global + /// events. Defaults to `true` for access-scope queries. + pub channel_ids_include_global: bool, /// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by /// the COUNT fallback path, which needs to fetch all matching events for /// post-filter counting. When None, the default clamp applies. @@ -122,6 +129,7 @@ impl EventQuery { ids: None, e_tags: None, channel_ids: None, + channel_ids_include_global: true, max_limit: None, shared_gated_reader: None, } @@ -404,20 +412,24 @@ pub(crate) async fn query_events_on( qb.push(format!(" AND {col_prefix}channel_id IS NULL")); } - // Multi-channel IN pushdown: restrict to events in any of these channels - // OR global events (channel_id IS NULL). Used by NIP-45 COUNT to enforce - // channel access at the SQL level without fetching all rows. + // Multi-channel IN pushdown. Access-scope queries retain global events; + // explicit multi-value #h filters do not. // - // SECURITY: Some(empty vec) means "user has access to NO channels" — - // only global events (channel_id IS NULL) should be returned. + // SECURITY: Some(empty vec) means "match no channels". Access-scope + // queries still retain globals; explicit #h queries match nothing. if let Some(ref ch_ids) = q.channel_ids { if ch_ids.is_empty() { - // No channel access — only global (non-channel) events visible. - qb.push(format!(" AND {col_prefix}channel_id IS NULL")); + if q.channel_ids_include_global { + qb.push(format!(" AND {col_prefix}channel_id IS NULL")); + } else { + qb.push(" AND FALSE"); + } } else { - qb.push(format!( - " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN (" - )); + qb.push(" AND ("); + if q.channel_ids_include_global { + qb.push(format!("{col_prefix}channel_id IS NULL OR ")); + } + qb.push(format!("{col_prefix}channel_id IN (")); let mut sep = qb.separated(", "); for ch in ch_ids { sep.push_bind(*ch); @@ -670,15 +682,21 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer qb.push(format!(" AND {col_prefix}channel_id IS NULL")); } - // Multi-channel IN pushdown for COUNT: restrict to accessible channels + global. - // SECURITY: Some(empty vec) = no channel access → global events only. + // Multi-channel IN pushdown for COUNT. Access-scope queries retain global + // events; explicit multi-value #h filters do not. if let Some(ref ch_ids) = q.channel_ids { if ch_ids.is_empty() { - qb.push(format!(" AND {col_prefix}channel_id IS NULL")); + if q.channel_ids_include_global { + qb.push(format!(" AND {col_prefix}channel_id IS NULL")); + } else { + qb.push(" AND FALSE"); + } } else { - qb.push(format!( - " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN (" - )); + qb.push(" AND ("); + if q.channel_ids_include_global { + qb.push(format!("{col_prefix}channel_id IS NULL OR ")); + } + qb.push(format!("{col_prefix}channel_id IN (")); let mut sep = qb.separated(", "); for ch in ch_ids { sep.push_bind(*ch); @@ -1878,6 +1896,70 @@ mod tests { .expect("sign timestamped event") } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn explicit_multi_channel_scope_is_applied_before_historical_page_limit() { + let pool = setup_pool().await; + let community_uuid = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_uuid); + let channel_a = make_test_channel(&pool, community_uuid, None).await; + let channel_b = make_test_channel(&pool, community_uuid, None).await; + let unrelated_c = make_test_channel(&pool, community_uuid, None).await; + let base = 1_800_000_000; + + let older_a = make_event_at(39_000, "older requested A", base + 1); + insert_event(&pool, community, &older_a, Some(channel_a)) + .await + .expect("insert requested A candidate"); + let requested_b = make_event_at(39_000, "requested B", base + 2); + insert_event(&pool, community, &requested_b, Some(channel_b)) + .await + .expect("insert requested B candidate"); + let newer_c = make_event_at(39_000, "newer unrelated C", base + 3); + insert_event(&pool, community, &newer_c, Some(unrelated_c)) + .await + .expect("insert unrelated C candidate"); + let global = make_event_at(39_000, "global candidate", base + 4); + insert_event(&pool, community, &global, None) + .await + .expect("insert global candidate"); + + let events = query_events( + &pool, + &EventQuery { + kinds: Some(vec![39_000]), + channel_ids: Some(vec![channel_a, channel_b]), + channel_ids_include_global: false, + limit: Some(1), + ..EventQuery::for_community(community) + }, + ) + .await + .expect("query explicit multi-channel page"); + + assert_eq!(events.len(), 1); + assert_eq!( + events[0].event.id, requested_b.id, + "newer unrelated channel C must not consume the requested A/B limit" + ); + + let partial_authorization_count = count_events( + &pool, + &EventQuery { + kinds: Some(vec![39_000]), + channel_ids: Some(vec![channel_a]), + channel_ids_include_global: false, + ..EventQuery::for_community(community) + }, + ) + .await + .expect("count one authorized channel from a multi-channel request"); + assert_eq!( + partial_authorization_count, 1, + "partial authorization must exclude requested B, unrelated C, and global rows" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn access_scope_is_applied_before_historical_page_limit() { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 0856c85cf36..8fdea4b3c02 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -981,6 +981,8 @@ async fn query_events_authed( .map(|v| serde_json::from_value(v.clone())) .collect::>() .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; + crate::handlers::req::extract_channel_ids_from_filters_limited(&filters) + .map_err(|()| api_error(StatusCode::BAD_REQUEST, "too many explicit channels"))?; // P-gated kinds (gift wraps, member notifications, observer frames) require // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. @@ -1005,10 +1007,18 @@ async fn query_events_authed( } // Get channels this user can access — same enforcement as WS REQ handler. - let accessible_channels = state + let mut accessible_channels = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + repair_requested_channel_access( + state, + tenant, + &filters, + &pubkey_bytes, + &mut accessible_channels, + ) + .await?; if filters.iter().any(|f| f.search.is_some()) { if has_mixed_search_filters(&filters) { @@ -1234,8 +1244,9 @@ async fn query_events_authed( tenant.community(), ) .await; - crate::handlers::req::apply_access_scope_to_query( + crate::handlers::req::apply_channel_scope_to_query( &mut query, + filter, extract_channel_from_filter(filter), &accessible_channels, ); @@ -1324,6 +1335,39 @@ async fn query_events_authed( Ok(Json(Value::Array(events))) } +async fn repair_requested_channel_access( + state: &AppState, + tenant: &TenantContext, + filters: &[nostr::Filter], + pubkey_bytes: &[u8], + accessible_channels: &mut Vec, +) -> Result<(), (StatusCode, Json)> { + for filter in filters { + let Some(requested) = + crate::handlers::req::extract_channel_ids_from_filters(std::slice::from_ref(filter)) + else { + continue; + }; + for channel_id in requested { + if accessible_channels.contains(&channel_id) { + continue; + } + let is_member = state + .db + .is_member(tenant.community(), channel_id, pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("channel membership confirmation: {e}")))?; + crate::handlers::req::resolve_request_local_access( + accessible_channels, + channel_id, + true, + Some(is_member), + ); + } + } + Ok(()) +} + /// Count events via HTTP bridge (NIP-98 auth). Returns `{"count": N}`. /// /// Enforces channel access: only counts events in channels the user can access. @@ -1415,6 +1459,8 @@ async fn count_events_authed( let filters: Vec = serde_json::from_slice(body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; + crate::handlers::req::extract_channel_ids_from_filters_limited(&filters) + .map_err(|()| api_error(StatusCode::BAD_REQUEST, "too many explicit channels"))?; // P-gated kinds enforcement — same as WS REQ and /query. let authed_pubkey_hex = pubkey.to_hex(); @@ -1438,10 +1484,18 @@ async fn count_events_authed( } // Get channels this user can access. - let accessible_channels = state + let mut accessible_channels = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + repair_requested_channel_access( + state, + tenant, + &filters, + &pubkey_bytes, + &mut accessible_channels, + ) + .await?; let mut total: u64 = 0; for filter in &filters { @@ -1463,9 +1517,19 @@ async fn count_events_authed( crate::handlers::req::filter_can_match_shared_gated_kinds(filter); // If filter targets a specific channel, verify access. - if let Some(ch_id) = extract_channel_from_filter(filter) { - if !accessible_channels.contains(&ch_id) { - continue; // Skip filters targeting inaccessible channels. + if crate::handlers::req::extract_channel_ids_from_filters(std::slice::from_ref(filter)) + .is_some() + { + let ch_id = extract_channel_from_filter(filter); + let requested = crate::handlers::req::extract_channel_ids_from_filters( + std::slice::from_ref(filter), + ) + .unwrap_or_default(); + if !requested + .iter() + .any(|channel_id| accessible_channels.contains(channel_id)) + { + continue; } // Channel is accessible — count with pushability check. let mut query = crate::handlers::req::build_event_query_from_filter( @@ -1475,6 +1539,12 @@ async fn count_events_authed( tenant.community(), ) .await; + crate::handlers::req::apply_channel_scope_to_query( + &mut query, + filter, + ch_id, + &accessible_channels, + ); // Shared-gated visibility pushdown: same as REQ and /query paths, so // the fallback's query_events call doesn't over-fetch private rows. if needs_shared_gate_filtering { diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index c37421e7e80..5fcfe70b91c 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -24,7 +24,6 @@ use crate::state::{ run_registered_community_connection, AppState, CommunityConnectionControl, CommunityDisconnectReason, }; -use buzz_pubsub::EventTopic; /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. const AUTH_TIMEOUT: Duration = Duration::from_secs(5); @@ -287,10 +286,18 @@ async fn handle_active_connection( let _ = auth_timeout_task.await; for removed in state.sub_registry.remove_connection(conn.conn_id) { - state - .pubsub - .release_topic(&conn.tenant, topic_for_subscription(removed.channel_id)) - .await; + if removed.scope.is_global() { + state + .pubsub + .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Global) + .await; + } + for &channel_id in removed.scope.channel_ids() { + state + .pubsub + .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Channel(channel_id)) + .await; + } } state.conn_manager.deregister(conn.conn_id); if let AuthState::Authenticated(ref auth_ctx) = *conn.auth_state.read().await { @@ -729,13 +736,6 @@ fn send_admission_result( } } -fn topic_for_subscription(channel_id: Option) -> EventTopic { - match channel_id { - Some(channel_id) => EventTopic::Channel(channel_id), - None => EventTopic::Global, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-relay/src/handlers/close.rs b/crates/buzz-relay/src/handlers/close.rs index d8ad1aa51f3..86f3d0da79b 100644 --- a/crates/buzz-relay/src/handlers/close.rs +++ b/crates/buzz-relay/src/handlers/close.rs @@ -5,7 +5,6 @@ use tracing::debug; use crate::connection::ConnectionState; use crate::protocol::RelayMessage; use crate::state::AppState; -use buzz_pubsub::EventTopic; /// Handle a CLOSE command — remove the subscription and send CLOSED acknowledgement. pub async fn handle_close(sub_id: String, conn: Arc, state: Arc) { @@ -16,20 +15,21 @@ pub async fn handle_close(sub_id: String, conn: Arc, state: Arc // Deregister from the fan-out index before sending CLOSED so no new // messages are routed to this sub after the client's CLOSE is acknowledged. if let Some(removed) = state.sub_registry.remove_subscription(conn_id, &sub_id) { - state - .pubsub - .release_topic(&conn.tenant, topic_for_subscription(removed.channel_id)) - .await; + if removed.scope.is_global() { + state + .pubsub + .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Global) + .await; + } + for &channel_id in removed.scope.channel_ids() { + state + .pubsub + .release_topic(&conn.tenant, buzz_pubsub::EventTopic::Channel(channel_id)) + .await; + } } conn.send(RelayMessage::closed(&sub_id, "")); debug!(conn_id = %conn_id, sub_id = %sub_id, "Subscription closed"); } - -fn topic_for_subscription(channel_id: Option) -> EventTopic { - match channel_id { - Some(channel_id) => EventTopic::Channel(channel_id), - None => EventTopic::Global, - } -} diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 3eeab5e807d..938674301e7 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -13,20 +13,8 @@ use crate::handlers::req::{ use crate::protocol::RelayMessage; use crate::state::AppState; -/// Extract a channel UUID from a single filter's `#h` tag. -fn extract_channel_from_filter(filter: &Filter) -> Option { - let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); - filter.generic_tags.get(&h_tag).and_then(|vs| { - if vs.len() == 1 { - vs.iter().next()?.parse::().ok() - } else { - None - } - }) -} - /// Handle a COUNT message: require auth, enforce channel access, execute filters, -/// return aggregate count. +/// and return the aggregate count. pub async fn handle_count( sub_id: String, filters: Vec, @@ -75,6 +63,23 @@ pub async fn handle_count( return; } + let requested_channel_sets = + match super::req::extract_channel_ids_from_filters_limited(&filters) { + Ok(_) => filters + .iter() + .map(|filter| { + super::req::extract_channel_ids_from_filters(std::slice::from_ref(filter)) + }) + .collect::>(), + Err(()) => { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: too many explicit channels", + )); + return; + } + }; + // Get channels this user can access — same enforcement as WS REQ handler. let mut accessible_channels = match state .get_accessible_channel_ids_cached(conn.tenant.community(), &pubkey_bytes) @@ -98,7 +103,7 @@ pub async fn handle_count( // For each filter, count matching events with channel access enforcement. let mut total: u64 = 0; - for filter in &filters { + for (filter, requested_channels) in filters.iter().zip(requested_channel_sets) { // Determine if this filter can match author-only kinds — if so, the // fast-path count_events() cannot be used because it doesn't do // per-event author filtering. @@ -117,38 +122,50 @@ pub async fn handle_count( let needs_result_gated_filtering = filter_can_match_result_gated_kinds(filter) && !result_gated_count_safe_for_pushdown(filter, &authed_pubkey_hex); - if let Some(ch_id) = extract_channel_from_filter(filter) { - // Filter targets a specific channel — verify access. Mirrors the WS - // REQ handler: a cache-negative may be a stale miss on a non-writer - // pod, so confirm uncached and repair the Vec request-locally via - // `super::req::resolve_request_local_access` (so a just-added channel - // is counted, and any later filter on the same channel sees it too). - let db_is_member = if accessible_channels.contains(&ch_id) { - None - } else { - match state - .db - .is_member(conn.tenant.community(), ch_id, &pubkey_bytes) - .await - { - Ok(member) => Some(member), - Err(e) => { - warn!(sub_id = %sub_id, "Channel membership confirmation failed: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); - return; - } + if let Some(requested_channels) = requested_channels { + for &ch_id in &requested_channels { + if accessible_channels.contains(&ch_id) { + continue; } - }; - if !super::req::resolve_request_local_access( - &mut accessible_channels, - ch_id, - token_channel_ids + let token_allows = token_channel_ids .as_deref() - .is_none_or(|allowed| allowed.contains(&ch_id)), - db_is_member, - ) { - continue; // Skip filters targeting inaccessible channels. + .is_none_or(|allowed| allowed.contains(&ch_id)); + let db_is_member = if token_allows { + match state + .db + .is_member(conn.tenant.community(), ch_id, &pubkey_bytes) + .await + { + Ok(member) => Some(member), + Err(e) => { + warn!(sub_id = %sub_id, "Channel membership confirmation failed: {e}"); + conn.send(RelayMessage::closed(&sub_id, "error: database error")); + return; + } + } + } else { + None + }; + super::req::resolve_request_local_access( + &mut accessible_channels, + ch_id, + token_allows, + db_is_member, + ); } + let authorized_requested: Vec<_> = requested_channels + .iter() + .copied() + .filter(|channel_id| accessible_channels.contains(channel_id)) + .collect(); + if authorized_requested.is_empty() { + continue; + } + // Preserve the original explicit multi-channel shape even when + // authorization narrows it to one channel. The helper must write + // that intersection into `channel_ids`; synthesizing `Some(A)` here + // would leave a query built from multi-#h completely unscoped. + let ch_id = (requested_channels.len() == 1).then_some(authorized_requested[0]); // Channel is accessible — count with pushability check. let mut query = super::req::build_event_query_from_filter( filter, @@ -157,6 +174,12 @@ pub async fn handle_count( conn.tenant.community(), ) .await; + super::req::apply_channel_scope_to_query( + &mut query, + filter, + ch_id, + &accessible_channels, + ); // Shared-gated visibility pushdown: pre-filter the fallback // query_events candidate page before ORDER/LIMIT. if needs_shared_gate_filtering { diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index fd7deadf51e..250fb4f9b92 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -33,6 +33,14 @@ const MAX_SUBSCRIPTIONS: usize = 1024; /// `buffer_unordered`), so dedupe/trace/error semantics are unchanged. pub(crate) const FILTER_QUERY_CONCURRENCY: usize = 4; +/// Maximum aggregate number of explicit `#h` values accepted in one REQ, +/// COUNT, HTTP `/query`, or HTTP `/count` request. +/// +/// Explicit channels may each require an uncached membership lookup and, for a +/// live WS subscription, a registry entry plus Redis topic retain. Bound the +/// values before any of that request-amplified work begins. +pub(crate) const MAX_EXPLICIT_CHANNEL_VALUES: usize = 128; + // Guard: keep the bound a small fraction of any sane Postgres pool size. // Raising it past this range requires re-running the relay bench and // reconsidering pool contention (see docs above). Compile-time — violating @@ -85,6 +93,18 @@ pub async fn handle_req( } }; + let channel_id = extract_channel_id_from_filters(&filters); + let requested_channel_ids = match extract_channel_ids_from_filters_limited(&filters) { + Ok(ids) => ids, + Err(()) => { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: too many explicit channels", + )); + return; + } + }; + let mut accessible_channels = if filters_are_nip43_membership_only(&filters) { metrics::counter!("buzz_req_global_access_resolution_skips_total", "kind" => "13534") .increment(1); @@ -106,8 +126,6 @@ pub async fn handle_req( accessible_channels.retain(|channel_id| allowed.contains(channel_id)); } - let channel_id = extract_channel_id_from_filters(&filters); - // Build the conformance `AbstractState` once at request entry. The // `Option` only goes `None` on malformed pubkey bytes (already a // separate failure path elsewhere); on the hot read path this is @@ -126,50 +144,70 @@ pub async fn handle_req( // `resolve_request_local_access`). Running this ahead of the search branch // is what fixes the search false-miss: a `#h=` search would // otherwise be scoped against the stale vector and return empty. - if let Some(ch_id) = channel_id { - let token_allows = token_channel_ids - .as_deref() - .is_none_or(|allowed| allowed.contains(&ch_id)); - let db_is_member = if !token_allows || accessible_channels.contains(&ch_id) { - None - } else { - match state - .db - .is_member(conn.tenant.community(), ch_id, &pubkey_bytes) - .await - { - Ok(member) => { - if let Some(state_snap) = trace_state.as_ref() { - crate::conformance::record_req_authcheck( - &state.tracer, - state_snap, - ch_id, - member, - ); + if let Some(requested) = requested_channel_ids.as_ref() { + for &ch_id in requested { + let token_allows = token_channel_ids + .as_deref() + .is_none_or(|allowed| allowed.contains(&ch_id)); + let db_is_member = if !token_allows || accessible_channels.contains(&ch_id) { + None + } else { + match state + .db + .is_member(conn.tenant.community(), ch_id, &pubkey_bytes) + .await + { + Ok(member) => { + if let Some(state_snap) = trace_state.as_ref() { + crate::conformance::record_req_authcheck( + &state.tracer, + state_snap, + ch_id, + member, + ); + } + Some(member) + } + Err(e) => { + warn!(conn_id = %conn_id, "Channel membership confirmation failed: {e}"); + conn.send(RelayMessage::closed(&sub_id, "error: database error")); + return; } - Some(member) - } - Err(e) => { - warn!(conn_id = %conn_id, "Channel membership confirmation failed: {e}"); - conn.send(RelayMessage::closed(&sub_id, "error: database error")); - return; } - } - }; - if !resolve_request_local_access( - &mut accessible_channels, - ch_id, - token_allows, - db_is_member, - ) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: not a channel member", - )); - return; + }; + // An OR filter may include inaccessible channels; retain every + // authorized requested channel and silently omit the others. + resolve_request_local_access( + &mut accessible_channels, + ch_id, + token_allows, + db_is_member, + ); } } + let authorized_requested_channels = requested_channel_ids.as_ref().map(|requested| { + requested + .iter() + .copied() + .filter(|channel_id| accessible_channels.contains(channel_id)) + .collect::>() + }); + // Partial authorization preserves NIP-01 OR semantics by omitting only + // inaccessible branches. If no valid requested channel survives, retain the + // established single-channel contract: reject instead of registering a + // subscription that can never produce an event or a terminal notice. + if authorized_requested_channels + .as_ref() + .is_some_and(|authorized| authorized.is_empty()) + { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: not a channel member", + )); + return; + } + // Applied BEFORE the NIP-50 search branch so that an authenticated member // cannot use `{"search":"...","kinds":[30174]}` (or similar for p-gated // kinds) to harvest indexed-but-globally-stored sensitive events. Search @@ -236,23 +274,39 @@ pub async fn handle_req( subs.insert(sub_id.clone(), filters.clone()); } - let replaced = state.sub_registry.register_scoped( - conn.tenant.community(), - conn_id, - sub_id.clone(), - filters.clone(), - channel_id, - ); + let replaced = if let Some(channel_ids) = authorized_requested_channels.as_ref() { + state.sub_registry.register_channels_scoped( + conn.tenant.community(), + conn_id, + sub_id.clone(), + filters.clone(), + channel_ids.clone(), + ) + } else { + state.sub_registry.register_scoped( + conn.tenant.community(), + conn_id, + sub_id.clone(), + filters.clone(), + None, + ) + }; if let Some(replaced) = replaced { + release_subscription_topics(&state, &conn.tenant, &replaced.scope).await; + } + if let Some(channel_ids) = authorized_requested_channels.as_ref() { + for &channel_id in channel_ids { + state + .pubsub + .retain_topic(&conn.tenant, EventTopic::Channel(channel_id)) + .await; + } + } else { state .pubsub - .release_topic(&conn.tenant, topic_for_subscription(replaced.channel_id)) + .retain_topic(&conn.tenant, EventTopic::Global) .await; } - state - .pubsub - .retain_topic(&conn.tenant, topic_for_subscription(channel_id)) - .await; debug!(conn_id = %conn_id, sub_id = %sub_id, "Subscription registered"); @@ -288,7 +342,12 @@ pub async fn handle_req( }; let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); - apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); + apply_channel_scope_to_query( + &mut params, + filter, + per_filter_channel, + &accessible_channels, + ); // Shared-gated visibility pushdown: set reader bytes so query_events // appends the SQL visibility clause before ORDER/LIMIT, preventing // newer private events from starving older shared ones off the page. @@ -785,11 +844,11 @@ pub(crate) fn count_fallback_exceeded(candidate_count: usize) -> bool { /// an exact count without post-filtering. /// /// Pushed constraints: kinds, authors (single or multi), ids, since, until, -/// channel_id (#h single), #p (single), #d (single, NIP-33-only kinds), #e (any), -/// channel_ids (injected by caller). +/// authorized channel scope (#h single or multi, injected by caller), #p (single), +/// #d (single, NIP-33-only kinds), #e (any). /// -/// Anything else (multi-#p, #t, #a, search, multi-#h, #d on non-NIP-33) -/// requires post-filtering and cannot use the fast COUNT path. +/// Anything else (multi-#p, #t, #a, search, #d on non-NIP-33) requires +/// post-filtering and cannot use the fast COUNT path. pub fn filter_fully_pushable(filter: &Filter) -> bool { // Check if filter exclusively targets NIP-33 kinds (needed for #d pushability). let is_nip33_only = filter.kinds.as_ref().is_some_and(|ks| { @@ -803,10 +862,8 @@ pub fn filter_fully_pushable(filter: &Filter) -> bool { let key = tag_key.to_string(); match key.as_str() { "h" => { - // Single #h is pushed as channel_id; multi-#h is not. - if tag_values.len() > 1 { - return false; - } + // The caller pushes the complete authorized #h set through + // EventQuery::channel_id/channel_ids before invoking COUNT. } "p" => { // Single #p is pushed via event_mentions join; multi is not. @@ -854,19 +911,20 @@ fn filters_are_nip43_membership_only(filters: &[Filter]) -> bool { }) } -/// Extract a channel UUID from a single filter's `#h` tag. +/// Extract the single channel UUID from a filter's `#h` tag. +/// +/// A multi-value `#h` filter has NIP-01 OR semantics, so it cannot be reduced +/// to one `EventQuery::channel_id` without dropping matches from the other +/// channels. Return `None` in that case and let the caller apply the accessible +/// channel set in SQL before the full filter is evaluated in Rust. fn extract_channel_id_from_filter(filter: &Filter) -> Option { - for (tag_key, tag_values) in filter.generic_tags.iter() { - let key = tag_key.to_string(); - if key == "h" { - for val in tag_values { - if let Ok(id) = val.parse::() { - return Some(id); - } - } - } + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + let values = filter.generic_tags.get(&h_tag)?; + if values.len() != 1 { + return None; } - None + + values.iter().next()?.parse::().ok() } /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. @@ -1002,30 +1060,96 @@ fn filter_to_query_params( } } -/// Push the caller's authorized channel set into logically global historical -/// queries so SQL `LIMIT` counts visible rows. Channel-less events remain in -/// scope by `EventQuery::channel_ids` contract; an explicit single-channel -/// filter keeps its narrower `channel_id` predicate. -pub(crate) fn apply_access_scope_to_query( +/// Push channel constraints into SQL before `LIMIT`. +/// +/// A valid multi-value `#h` is narrowed to the requested channels the reader +/// may access. Invalid values are ignored, and an empty authorized result is an +/// explicit match-nothing scope rather than a global query. Filters without +/// `#h` retain the full accessible-channel scope plus global events. +pub(crate) fn apply_channel_scope_to_query( query: &mut EventQuery, + filter: &Filter, channel_id: Option, accessible_channels: &[uuid::Uuid], ) { - if channel_id.is_none() { + if channel_id.is_some() { + return; + } + + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + if let Some(values) = filter.generic_tags.get(&h_tag) { + query.channel_ids = Some( + values + .iter() + .filter_map(|value| value.parse::().ok()) + .filter(|requested| accessible_channels.contains(requested)) + .collect(), + ); + query.channel_ids_include_global = false; + } else { query.channel_ids = Some(accessible_channels.to_vec()); } } -/// Extract a single channel UUID from filter generic tags, or `None` if the -/// subscription is logically global. -/// -/// Checks the `"h"` tag key — channel-scoped subscriptions use `#h = `. -/// -/// Returns `None` when: -/// - Any filter has no channel tag (that filter matches all channels → global sub), or -/// - Multiple distinct channel UUIDs appear across filters (can't index under one channel). +/// Extract the complete channel set when every filter is explicitly #h-scoped. +/// `None` means at least one filter is community-global. /// -/// Callers that receive `None` treat the subscription as global (slow-path fan-out). +/// The aggregate value count is checked before UUID parsing or membership I/O; +/// duplicate and malformed values still consume the request budget. +pub(crate) fn extract_channel_ids_from_filters_limited( + filters: &[Filter], +) -> Result>, ()> { + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + let value_count = filters.iter().try_fold(0usize, |count, filter| { + let additional = filter + .generic_tags + .get(&h_tag) + .map_or(0, |values| values.len()); + count.checked_add(additional).ok_or(()) + })?; + if value_count > MAX_EXPLICIT_CHANNEL_VALUES { + return Err(()); + } + + Ok(extract_channel_ids_from_filters(filters)) +} + +/// Extract the complete channel set without applying the aggregate request budget. +/// Callers that can trigger I/O must validate first with +/// [`extract_channel_ids_from_filters_limited`]. +pub(crate) fn extract_channel_ids_from_filters(filters: &[Filter]) -> Option> { + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + let mut channel_ids = Vec::new(); + for filter in filters { + let values = filter.generic_tags.get(&h_tag)?; + for value in values { + if let Ok(channel_id) = value.parse::() { + if !channel_ids.contains(&channel_id) { + channel_ids.push(channel_id); + } + } + } + } + Some(channel_ids) +} + +async fn release_subscription_topics( + state: &AppState, + tenant: &TenantContext, + scope: &crate::subscription::SubscriptionScope, +) { + if scope.is_global() { + state.pubsub.release_topic(tenant, EventTopic::Global).await; + } else { + for &channel_id in scope.channel_ids() { + state + .pubsub + .release_topic(tenant, EventTopic::Channel(channel_id)) + .await; + } + } +} + fn extract_channel_id_from_filters(filters: &[Filter]) -> Option { let mut found_id: Option = None; for f in filters { @@ -1289,13 +1413,6 @@ pub(crate) fn author_only_filters_authorized(filters: &[Filter], authed_pubkey_h }) } -fn topic_for_subscription(channel_id: Option) -> EventTopic { - match channel_id { - Some(channel_id) => EventTopic::Channel(channel_id), - None => EventTopic::Global, - } -} - #[cfg(test)] mod tests { use super::*; @@ -1308,7 +1425,7 @@ mod tests { uuid::Uuid::new_v4(), )); - apply_access_scope_to_query(&mut query, None, &accessible); + apply_channel_scope_to_query(&mut query, &Filter::new(), None, &accessible); assert_eq!(query.channel_ids.as_deref(), Some(accessible.as_slice())); } @@ -1322,7 +1439,7 @@ mod tests { )); query.channel_id = Some(channel); - apply_access_scope_to_query(&mut query, Some(channel), &accessible); + apply_channel_scope_to_query(&mut query, &Filter::new(), Some(channel), &accessible); assert!(query.channel_ids.is_none()); assert_eq!(query.channel_id, Some(channel)); @@ -1551,6 +1668,171 @@ mod tests { assert_eq!(extract_channel_id_from_filters(&filters), Some(channel_id)); } + #[test] + fn extract_channel_id_from_multi_value_filter_returns_none() { + let channel_a = uuid::Uuid::new_v4(); + let channel_b = uuid::Uuid::new_v4(); + let filter: Filter = serde_json::from_value(serde_json::json!({ + "#h": [channel_a.to_string(), channel_b.to_string()], + })) + .unwrap(); + + assert_eq!(extract_channel_id_from_filter(&filter), None); + assert_eq!( + filter_to_query_params( + &filter, + extract_channel_id_from_filter(&filter), + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + ) + .channel_id, + None, + "multi-channel OR filters must not be narrowed to their first channel", + ); + } + + #[test] + fn valid_channel_union_survives_malformed_or_empty_explicit_siblings() { + let valid = uuid::Uuid::new_v4(); + for sibling in [ + serde_json::json!({"#h": ["not-a-uuid"]}), + serde_json::json!({"#h": []}), + ] { + let filters = [ + filter_with_channel(valid), + serde_json::from_value(sibling).expect("parse sibling filter"), + ]; + assert_eq!( + extract_channel_ids_from_filters(&filters), + Some(vec![valid]), + ); + } + + let malformed_only: Filter = + serde_json::from_value(serde_json::json!({"#h": ["not-a-uuid"]})) + .expect("parse malformed filter"); + assert_eq!( + extract_channel_ids_from_filters(&[malformed_only]), + Some(Vec::new()), + "malformed-only explicit scope must remain match-nothing, never global", + ); + } + + #[test] + fn explicit_channel_limit_is_aggregate_and_counts_every_value() { + let channel_values = |count: usize| { + (0..count) + .map(|_| uuid::Uuid::new_v4().to_string()) + .collect::>() + }; + let at_limit: Filter = serde_json::from_value(serde_json::json!({ + "#h": channel_values(MAX_EXPLICIT_CHANNEL_VALUES), + })) + .unwrap(); + assert!(extract_channel_ids_from_filters_limited(&[at_limit]).is_ok()); + + let first: Filter = serde_json::from_value(serde_json::json!({ + "#h": channel_values(MAX_EXPLICIT_CHANNEL_VALUES), + })) + .unwrap(); + let duplicate_over_limit: Filter = serde_json::from_value(serde_json::json!({ + "#h": [uuid::Uuid::nil().to_string()], + })) + .unwrap(); + assert_eq!( + extract_channel_ids_from_filters_limited(&[first, duplicate_over_limit]), + Err(()), + ); + + let global_then_over_limit = [ + Filter::new(), + serde_json::from_value(serde_json::json!({ + "#h": channel_values(MAX_EXPLICIT_CHANNEL_VALUES + 1), + })) + .unwrap(), + ]; + assert_eq!( + extract_channel_ids_from_filters_limited(&global_then_over_limit), + Err(()), + "a global filter must not hide an over-limit explicit filter", + ); + } + + #[test] + fn multi_value_h_scope_intersects_access_before_limit() { + let channel_a = uuid::Uuid::new_v4(); + let channel_b = uuid::Uuid::new_v4(); + let unrelated_c = uuid::Uuid::new_v4(); + let unauthorized = uuid::Uuid::new_v4(); + let filter: Filter = serde_json::from_value(serde_json::json!({ + "#h": [ + channel_a.to_string(), + channel_b.to_string(), + unauthorized.to_string(), + "not-a-uuid" + ], + "limit": 1 + })) + .unwrap(); + let mut query = filter_to_query_params( + &filter, + extract_channel_id_from_filter(&filter), + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + ); + + apply_channel_scope_to_query( + &mut query, + &filter, + None, + &[channel_a, channel_b, unrelated_c], + ); + + let scoped_channels = query.channel_ids.expect("explicit channel scope"); + assert_eq!(scoped_channels.len(), 2); + assert!(scoped_channels.contains(&channel_a)); + assert!(scoped_channels.contains(&channel_b)); + assert!(!query.channel_ids_include_global); + assert_eq!(query.limit, Some(1)); + } + + #[test] + fn multi_value_h_scope_remains_explicit_when_only_one_channel_is_authorized() { + let authorized = uuid::Uuid::new_v4(); + let unauthorized = uuid::Uuid::new_v4(); + let filter: Filter = serde_json::from_value(serde_json::json!({ + "#h": [authorized.to_string(), unauthorized.to_string()], + })) + .unwrap(); + let mut query = filter_to_query_params( + &filter, + extract_channel_id_from_filter(&filter), + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + ); + + apply_channel_scope_to_query(&mut query, &filter, None, &[authorized]); + + assert_eq!(query.channel_id, None); + assert_eq!(query.channel_ids, Some(vec![authorized])); + assert!(!query.channel_ids_include_global); + } + + #[test] + fn empty_or_unauthorized_h_scope_matches_nothing() { + for values in [serde_json::json!([]), serde_json::json!(["not-a-uuid"])] { + let filter: Filter = + serde_json::from_value(serde_json::json!({ "#h": values })).unwrap(); + let mut query = filter_to_query_params( + &filter, + None, + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + ); + + apply_channel_scope_to_query(&mut query, &filter, None, &[uuid::Uuid::new_v4()]); + + assert_eq!(query.channel_ids, Some(Vec::new())); + assert!(!query.channel_ids_include_global); + } + } + #[test] fn test_extract_channel_id_mixed_channels_returns_none() { let channel_a = uuid::Uuid::new_v4(); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 0dc6cbd5039..282ea776577 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -116,20 +116,24 @@ async fn evict_conn_channel_subscriptions( if let Some(subscriptions) = state.conn_manager.subscriptions_for(conn_id) { let mut conn_subscriptions = subscriptions.lock().await; - for (sub_id, _) in &removed { - conn_subscriptions.remove(sub_id); + for update in &removed { + if update.removed { + conn_subscriptions.remove(&update.sub_id); + } } } - for (sub_id, removed_scope) in removed { + for update in removed { state .pubsub - .release_topic(tenant, topic_for_subscription(removed_scope.channel_id)) + .release_topic(tenant, buzz_pubsub::EventTopic::Channel(channel_id)) .await; - let _ = state.conn_manager.send_to( - conn_id, - RelayMessage::closed(&sub_id, "restricted: channel access revoked"), - ); + if update.removed { + let _ = state.conn_manager.send_to( + conn_id, + RelayMessage::closed(&update.sub_id, "restricted: channel access revoked"), + ); + } } } @@ -3367,13 +3371,6 @@ pub async fn publish_nipia_unarchived( .await } -fn topic_for_subscription(channel_id: Option) -> EventTopic { - match channel_id { - Some(channel_id) => EventTopic::Channel(channel_id), - None => EventTopic::Global, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-relay/src/subscription.rs b/crates/buzz-relay/src/subscription.rs index 7a62188d3a6..3a82ea27f54 100644 --- a/crates/buzz-relay/src/subscription.rs +++ b/crates/buzz-relay/src/subscription.rs @@ -13,7 +13,39 @@ pub type ConnId = Uuid; /// Subscription identifier — the client-supplied string from a REQ message. pub type SubId = String; /// Stored subscription entry: filters paired with server-resolved community and optional channel scope. -pub type SubEntry = (Vec, CommunityId, Option); +pub type SubEntry = (Vec, CommunityId, SubscriptionScope); + +/// Server-resolved live-routing scope for a subscription. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubscriptionScope { + /// Community-global events only. + Global, + /// Events from any of these authorized channels. + Channels(Vec), +} + +impl SubscriptionScope { + fn matches_channel(&self, channel_id: Option) -> bool { + match (self, channel_id) { + (Self::Global, None) => true, + (Self::Channels(channels), Some(channel_id)) => channels.contains(&channel_id), + _ => false, + } + } + + /// Return the channels retained by this routing scope. + pub fn channel_ids(&self) -> &[Uuid] { + match self { + Self::Global => &[], + Self::Channels(channels) => channels, + } + } + + /// Whether this routing scope retains the community-global topic. + pub fn is_global(&self) -> bool { + matches!(self, Self::Global) + } +} /// Index key combining a channel and event kind for O(1) fan-out lookups. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -32,12 +64,21 @@ struct GlobalPKindIndexKey { } /// A removed subscription's server-resolved routing scope. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct RemovedSubscription { /// Server-resolved community this subscription belonged to. pub community_id: CommunityId, - /// Tenant-local channel scope; `None` means the community-global topic. - pub channel_id: Option, + /// Server-resolved topics retained by the removed subscription. + pub scope: SubscriptionScope, +} + +/// Result of removing one revoked channel from a live subscription scope. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelSubscriptionUpdate { + /// Client-supplied subscription identifier. + pub sub_id: SubId, + /// Whether no authorized channels remain and the subscription was removed. + pub removed: bool, } /// Thread-safe registry of active subscriptions with targeted in-memory fan-out indexes. @@ -73,42 +114,77 @@ impl SubscriptionRegistry { sub_id: SubId, filters: Vec, channel_id: Option, + ) -> Option { + let scope = channel_id + .map(|channel_id| SubscriptionScope::Channels(vec![channel_id])) + .unwrap_or(SubscriptionScope::Global); + self.register_with_scope(community_id, conn_id, sub_id, filters, scope) + } + + /// Register a subscription under every authorized requested channel. + pub fn register_channels_scoped( + &self, + community_id: CommunityId, + conn_id: ConnId, + sub_id: SubId, + filters: Vec, + channel_ids: Vec, + ) -> Option { + self.register_with_scope( + community_id, + conn_id, + sub_id, + filters, + SubscriptionScope::Channels(channel_ids), + ) + } + + fn register_with_scope( + &self, + community_id: CommunityId, + conn_id: ConnId, + sub_id: SubId, + filters: Vec, + scope: SubscriptionScope, ) -> Option { let removed = self.remove_subscription(conn_id, &sub_id); - self.subs - .entry(conn_id) - .or_default() - .insert(sub_id.clone(), (filters.clone(), community_id, channel_id)); + self.subs.entry(conn_id).or_default().insert( + sub_id.clone(), + (filters.clone(), community_id, scope.clone()), + ); metrics::gauge!("buzz_subscriptions_active").increment(1.0); - if let Some(ch_id) = channel_id { - match extract_kinds_from_filters(&filters) { - None => { - // At least one filter has no `kinds` constraint — wildcard, - // this sub wants all kinds in this channel. - self.channel_wildcard_index - .entry((community_id, ch_id)) - .or_default() - .push((conn_id, sub_id.clone())); - } - Some(kinds) if kinds.is_empty() => { - // All filters had explicit empty kinds lists (`kinds: []`). - // Per NIP-01, `kinds: []` means "match no kinds" — this - // subscription will never receive any events. Do not index it - // anywhere; `filters_match` will reject all events at fan-out. - } - Some(kinds) => { - for kind in kinds { - let key = IndexKey { - channel_id: ch_id, - kind, - }; - self.channel_kind_index - .entry((community_id, key)) + if let SubscriptionScope::Channels(channel_ids) = &scope { + for ch_id in channel_ids { + let ch_id = *ch_id; + match extract_kinds_from_filters(&filters) { + None => { + // At least one filter has no `kinds` constraint — wildcard, + // this sub wants all kinds in this channel. + self.channel_wildcard_index + .entry((community_id, ch_id)) .or_default() .push((conn_id, sub_id.clone())); } + Some(kinds) if kinds.is_empty() => { + // All filters had explicit empty kinds lists (`kinds: []`). + // Per NIP-01, `kinds: []` means "match no kinds" — this + // subscription will never receive any events. Do not index it + // anywhere; `filters_match` will reject all events at fan-out. + } + Some(kinds) => { + for kind in kinds { + let key = IndexKey { + channel_id: ch_id, + kind, + }; + self.channel_kind_index + .entry((community_id, key)) + .or_default() + .push((conn_id, sub_id.clone())); + } + } } } } else { @@ -177,16 +253,16 @@ impl SubscriptionRegistry { F: FnOnce(), { let mut conn_subs = self.subs.get_mut(&conn_id)?; - let (filters, community_id, channel_id) = conn_subs.remove(sub_id)?; + let (filters, community_id, scope) = conn_subs.remove(sub_id)?; after_remove(); - self.remove_from_index(conn_id, sub_id, &filters, community_id, channel_id); + self.remove_from_index(conn_id, sub_id, &filters, community_id, &scope); drop(conn_subs); metrics::gauge!("buzz_subscriptions_active").decrement(1.0); Some(RemovedSubscription { community_id, - channel_id, + scope, }) } @@ -195,11 +271,11 @@ impl SubscriptionRegistry { let mut removed = Vec::new(); if let Some((_, conn_subs)) = self.subs.remove(&conn_id) { let count = conn_subs.len(); - for (sub_id, (filters, community_id, channel_id)) in &conn_subs { - self.remove_from_index(conn_id, sub_id, filters, *community_id, *channel_id); + for (sub_id, (filters, community_id, scope)) in &conn_subs { + self.remove_from_index(conn_id, sub_id, filters, *community_id, scope); removed.push(RemovedSubscription { community_id: *community_id, - channel_id: *channel_id, + scope: scope.clone(), }); } metrics::gauge!("buzz_subscriptions_active").decrement(count as f64); @@ -207,34 +283,58 @@ impl SubscriptionRegistry { removed } - /// Remove all subscriptions on `conn_id` scoped to `channel_id` in one community. + /// Remove one revoked channel from every matching subscription in a community. + /// Multi-channel subscriptions are re-indexed with their remaining scope; + /// subscriptions with no channels left are removed entirely. pub fn remove_channel_subscriptions_scoped( &self, community_id: CommunityId, conn_id: ConnId, channel_id: Uuid, - ) -> Vec<(SubId, RemovedSubscription)> { + ) -> Vec { let sub_ids: Vec = self .subs .get(&conn_id) .map(|conn_subs| { conn_subs .iter() - .filter_map(|(sub_id, (_, sub_community_id, sub_channel_id))| { - (*sub_community_id == community_id && *sub_channel_id == Some(channel_id)) - .then_some(sub_id.clone()) + .filter_map(|(sub_id, (_, sub_community_id, scope))| { + (*sub_community_id == community_id + && scope.channel_ids().contains(&channel_id)) + .then_some(sub_id.clone()) }) .collect() }) .unwrap_or_default(); - sub_ids - .into_iter() - .filter_map(|sub_id| { - let removed = self.remove_subscription(conn_id, &sub_id)?; - Some((sub_id, removed)) - }) - .collect() + let mut updates = Vec::with_capacity(sub_ids.len()); + for sub_id in sub_ids { + let Some(mut conn_subs) = self.subs.get_mut(&conn_id) else { + break; + }; + let Some((filters, _, scope)) = conn_subs.get_mut(&sub_id) else { + continue; + }; + let filters = filters.clone(); + let SubscriptionScope::Channels(channel_ids) = scope else { + continue; + }; + channel_ids.retain(|candidate| *candidate != channel_id); + let removed = channel_ids.is_empty(); + self.remove_from_index( + conn_id, + &sub_id, + &filters, + community_id, + &SubscriptionScope::Channels(vec![channel_id]), + ); + if removed { + conn_subs.remove(&sub_id); + metrics::gauge!("buzz_subscriptions_active").decrement(1.0); + } + updates.push(ChannelSubscriptionUpdate { sub_id, removed }); + } + updates } /// Test-only convenience wrapper preserving the original single-tenant test API. @@ -242,7 +342,8 @@ impl SubscriptionRegistry { pub fn remove_channel_subscriptions(&self, conn_id: ConnId, channel_id: Uuid) -> Vec { self.remove_channel_subscriptions_scoped(test_community(), conn_id, channel_id) .into_iter() - .map(|(sub_id, _)| sub_id) + .filter(|update| update.removed) + .map(|update| update.sub_id) .collect() } @@ -441,12 +542,12 @@ impl SubscriptionRegistry { seen: &mut HashSet<(ConnId, SubId)>, ) { if let Some(conn_subs) = self.subs.get(&conn_id) { - if let Some((filters, sub_community_id, sub_channel_id)) = conn_subs.get(sub_id) { + if let Some((filters, sub_community_id, scope)) = conn_subs.get(sub_id) { // Candidate snapshots can become stale while a same-ID replacement // moves the subscription. Re-check its authoritative scope before // matching so an old index entry cannot deliver across scopes. if *sub_community_id == community_id - && *sub_channel_id == event.channel_id + && scope.matches_channel(event.channel_id) && filters_match(filters, event) { let entry = (conn_id, sub_id.to_string()); @@ -466,42 +567,45 @@ impl SubscriptionRegistry { sub_id: &str, filters: &[Filter], community_id: CommunityId, - channel_id: Option, + scope: &SubscriptionScope, ) { - if let Some(ch_id) = channel_id { - match extract_kinds_from_filters(filters) { - // None = wildcard (at least one filter had no kinds constraint). - None => { - // Was in wildcard index. - if let Some(mut entries) = - self.channel_wildcard_index.get_mut(&(community_id, ch_id)) - { - entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id)); - if entries.is_empty() { - drop(entries); - self.channel_wildcard_index.remove(&(community_id, ch_id)); - } - } - } - Some(kinds) if kinds.is_empty() => { - // `kinds: []` subscriptions are never indexed (they match nothing), - // so there is nothing to remove here. - } - Some(kinds) => { - // Was in kind-specific index. - for kind in kinds { - let key = IndexKey { - channel_id: ch_id, - kind, - }; - if let Some(mut entries) = self - .channel_kind_index - .get_mut(&(community_id, key.clone())) + if let SubscriptionScope::Channels(channel_ids) = scope { + for ch_id in channel_ids { + let ch_id = *ch_id; + match extract_kinds_from_filters(filters) { + // None = wildcard (at least one filter had no kinds constraint). + None => { + // Was in wildcard index. + if let Some(mut entries) = + self.channel_wildcard_index.get_mut(&(community_id, ch_id)) { entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id)); if entries.is_empty() { drop(entries); - self.channel_kind_index.remove(&(community_id, key)); + self.channel_wildcard_index.remove(&(community_id, ch_id)); + } + } + } + Some(kinds) if kinds.is_empty() => { + // `kinds: []` subscriptions are never indexed (they match nothing), + // so there is nothing to remove here. + } + Some(kinds) => { + // Was in kind-specific index. + for kind in kinds { + let key = IndexKey { + channel_id: ch_id, + kind, + }; + if let Some(mut entries) = self + .channel_kind_index + .get_mut(&(community_id, key.clone())) + { + entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id)); + if entries.is_empty() { + drop(entries); + self.channel_kind_index.remove(&(community_id, key)); + } } } } @@ -686,6 +790,49 @@ mod tests { assert_eq!(matches[0].1, sub_id); } + #[test] + fn multi_channel_subscription_fans_out_only_requested_channels() { + let registry = SubscriptionRegistry::new(); + let conn_id = Uuid::new_v4(); + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let unrelated = Uuid::new_v4(); + let sub_id = "multi-channel".to_string(); + let filters = vec![Filter::new() + .kind(Kind::TextNote) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + channel_a.to_string(), + ) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::H), + channel_b.to_string(), + )]; + + registry.register_channels_scoped( + test_community(), + conn_id, + sub_id.clone(), + filters, + vec![channel_a, channel_b], + ); + + assert_eq!( + registry.fan_out(&make_stored_event(Kind::TextNote, Some(channel_a))), + vec![(conn_id, sub_id.clone())] + ); + assert_eq!( + registry.fan_out(&make_stored_event(Kind::TextNote, Some(channel_b))), + vec![(conn_id, sub_id)] + ); + assert!(registry + .fan_out(&make_stored_event(Kind::TextNote, Some(unrelated))) + .is_empty()); + assert!(registry + .fan_out(&make_stored_event(Kind::TextNote, None)) + .is_empty()); + } + #[test] fn test_subscription_registry_remove() { let registry = SubscriptionRegistry::new(); @@ -1688,6 +1835,54 @@ mod tests { ); } + #[test] + fn revoking_one_channel_keeps_multi_channel_subscription_live() { + let registry = SubscriptionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xaaaa)); + let conn = Uuid::new_v4(); + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let filters = vec![Filter::new().kind(Kind::TextNote)]; + registry.register_channels_scoped( + community, + conn, + "multi".to_string(), + filters, + vec![channel_a, channel_b], + ); + + let updates = registry.remove_channel_subscriptions_scoped(community, conn, channel_a); + assert_eq!( + updates, + vec![ChannelSubscriptionUpdate { + sub_id: "multi".to_string(), + removed: false, + }] + ); + assert!(registry + .fan_out_scoped( + community, + &make_stored_event(Kind::TextNote, Some(channel_a)) + ) + .is_empty()); + assert_eq!( + registry.fan_out_scoped( + community, + &make_stored_event(Kind::TextNote, Some(channel_b)) + ), + vec![(conn, "multi".to_string())] + ); + + let updates = registry.remove_channel_subscriptions_scoped(community, conn, channel_b); + assert_eq!( + updates, + vec![ChannelSubscriptionUpdate { + sub_id: "multi".to_string(), + removed: true, + }] + ); + } + #[test] fn per_community_subscriptions_snapshot_is_correctly_scoped() { // Verify that per_community_subscriptions() returns the correct diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 5d5ad8916c3..882cbabfe22 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -72,8 +72,9 @@ fn nip98_post_header(keys: &Keys, url: &str, body: &str) -> String { } async fn e2e_db_pool() -> sqlx::Pool { - let database_url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { + "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string() // sadscan:disable np.postgres.1 + }); sqlx::postgres::PgPoolOptions::new() .max_connections(1) .connect(&database_url) @@ -721,6 +722,81 @@ async fn test_stored_events_returned_before_eose() { client.disconnect().await.expect("disconnect"); } +/// An explicit `#h` branch that cannot match must not cancel a valid OR sibling. +/// The valid channel remains usable for historical delivery and live fan-out; +/// malformed-only requests still close because no authorized UUID survives. +#[tokio::test] +#[ignore] +async fn test_valid_channel_survives_malformed_or_empty_h_sibling() { + let url = relay_url(); + let kind: u16 = 9; + let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + + for (label, sibling) in [ + ( + "malformed", + serde_json::json!({"kinds": [kind], "#h": ["not-a-uuid"]}), + ), + ("empty", serde_json::json!({"kinds": [kind], "#h": []})), + ] { + let historical = format!("{label}-historical-{}", Uuid::new_v4()); + let ok = client + .send_text_message(&keys, &channel, &historical, kind) + .await + .expect("send historical event"); + assert!(ok.accepted, "historical event rejected: {}", ok.message); + + let valid = Filter::new() + .kind(Kind::Custom(kind)) + .custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); + let sibling: Filter = serde_json::from_value(sibling).expect("parse sibling filter"); + let sid = sub_id(label); + client + .subscribe(&sid, vec![valid, sibling]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("valid sibling history followed by EOSE"); + assert!( + events.iter().any(|event| event.content == historical), + "valid sibling history missing for {label} #h branch: {events:?}", + ); + + let live = format!("{label}-live-{}", Uuid::new_v4()); + let ok = client + .send_text_message(&keys, &channel, &live, kind) + .await + .expect("send live event"); + assert!(ok.accepted, "live event rejected: {}", ok.message); + let message = client + .recv_event(Duration::from_secs(5)) + .await + .expect("receive post-EOSE live event"); + match message { + RelayMessage::Event { + subscription_id, + event, + } => { + assert_eq!(subscription_id, sid); + assert_eq!(event.content, live); + } + other => panic!("expected live EVENT for {label} sibling, got {other:?}"), + } + + client + .close_subscription(&sid) + .await + .expect("close subscription"); + } + + client.disconnect().await.expect("disconnect"); +} + /// Ephemeral events (kind 20000–29999) must be accepted but not persisted. #[tokio::test] #[ignore] diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index 2e77645f853..c4e5d38c8ba 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use serde::Serialize; use serde_json::Value; use tauri::State; @@ -103,34 +105,73 @@ pub async fn get_channel_workflows( Ok(events.iter().map(workflow_from_event).collect()) } -/// Fetch workflows across many channels in a single relay round-trip. +// Keep this aligned with the relay's aggregate explicit-`#h` request bound. +// Each filter below carries exactly one explicit value so old relays retain the +// known-compatible shape while current relays cannot reject large memberships. +const WORKFLOW_QUERY_CHANNEL_BATCH_SIZE: usize = 128; + +/// Fetch workflows across many channels using bounded relay round-trips. /// /// The Workflows overview screen previously issued one `get_channel_workflows` /// query per member channel (`Promise.all` fanout in `WorkflowsView`), i.e. N -/// relay POSTs. A nostr `#h` filter matches ANY of its listed values, so one -/// query with all channel ids returns the same set. Each `WorkflowWire` carries -/// its own `channel_id` (from the event's `h` tag), so the frontend can still -/// group results by channel. Neither this nor the per-channel command sets a -/// `limit`, so batching does not change result completeness. +/// relay POSTs. This sends one single-channel filter per channel, in requests of +/// at most 128 filters. Using one multi-value `#h` filter is equivalent under +/// NIP-01, but older relays incorrectly narrowed that shape to its first +/// channel. Each `WorkflowWire` carries its own `channel_id` (from the event's +/// `h` tag), so the frontend can still group results by channel. Neither this +/// nor the per-channel command sets a `limit`, so batching does not change +/// result completeness. Results are deduplicated by signed event ID in case a +/// caller supplies duplicate channel IDs. #[tauri::command] pub async fn get_channels_workflows( channel_ids: Vec, state: State<'_, AppState>, ) -> Result, String> { - if channel_ids.is_empty() { - return Ok(Vec::new()); + let filter_batches = channel_workflow_filter_batches(channel_ids)?; + let mut seen_event_ids = HashSet::new(); + let mut workflows = Vec::new(); + + for filters in filter_batches { + let events = query_relay(&state, &filters).await?; + append_unique_workflows(&mut workflows, &mut seen_event_ids, &events); } - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [30620], - "#h": channel_ids, - })], - ) - .await?; + Ok(workflows) +} - Ok(events.iter().map(workflow_from_event).collect()) +fn append_unique_workflows( + workflows: &mut Vec, + seen_event_ids: &mut HashSet, + events: &[nostr::Event], +) { + workflows.extend( + events + .iter() + .filter(|event| seen_event_ids.insert(event.id)) + .map(workflow_from_event), + ); +} + +fn channel_workflow_filter_batches(channel_ids: Vec) -> Result>, String> { + let filters = channel_workflow_filters(channel_ids)?; + Ok(filters + .chunks(WORKFLOW_QUERY_CHANNEL_BATCH_SIZE) + .map(<[Value]>::to_vec) + .collect()) +} + +fn channel_workflow_filters(channel_ids: Vec) -> Result, String> { + channel_ids + .into_iter() + .map(|channel_id| { + let channel_id = uuid::Uuid::parse_str(channel_id.trim()) + .map_err(|_| "invalid channel id".to_string())?; + Ok(serde_json::json!({ + "kinds": [30620], + "#h": [channel_id.to_string()], + })) + }) + .collect() } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/workflows_tests.rs b/desktop/src-tauri/src/commands/workflows_tests.rs index 4adbd521771..8522d233c38 100644 --- a/desktop/src-tauri/src/commands/workflows_tests.rs +++ b/desktop/src-tauri/src/commands/workflows_tests.rs @@ -192,6 +192,81 @@ fn workflow_wire_serializes_with_snake_case_keys() { } } +#[test] +fn multi_channel_workflow_query_uses_one_filter_per_channel() { + let other_channel = "33333333-3333-3333-3333-333333333333"; + let filters = channel_workflow_filters(vec![CHAN.to_string(), other_channel.to_string()]) + .expect("valid channels"); + + assert_eq!(filters.len(), 2); + assert_eq!( + filters[0], + serde_json::json!({ + "kinds": [30620], + "#h": [CHAN], + }) + ); + assert_eq!( + filters[1], + serde_json::json!({ + "kinds": [30620], + "#h": [other_channel], + }) + ); +} + +#[test] +fn workflow_queries_batch_above_relay_explicit_channel_limit() { + let channel_ids = (0..WORKFLOW_QUERY_CHANNEL_BATCH_SIZE + 1) + .map(|index| uuid::Uuid::from_u128(index as u128 + 1).to_string()) + .collect(); + let batches = channel_workflow_filter_batches(channel_ids).expect("valid channels"); + + assert_eq!(batches.len(), 2); + assert_eq!(batches[0].len(), WORKFLOW_QUERY_CHANNEL_BATCH_SIZE); + assert_eq!(batches[1].len(), 1); + assert!(batches.iter().flatten().all(|filter| filter["#h"] + .as_array() + .is_some_and(|values| values.len() == 1))); +} + +#[test] +fn workflow_query_results_are_deduplicated_by_event_id() { + let first = wf_event(WF, CHAN, YAML); + let second_workflow = "33333333-3333-3333-3333-333333333333"; + let second = wf_event(second_workflow, CHAN, YAML); + let mut workflows = Vec::new(); + let mut seen_event_ids = HashSet::new(); + + append_unique_workflows( + &mut workflows, + &mut seen_event_ids, + &[first.clone(), second.clone()], + ); + append_unique_workflows(&mut workflows, &mut seen_event_ids, &[first, second]); + + assert_eq!(workflows.len(), 2); + assert_eq!(workflows[0].id, WF); + assert_eq!(workflows[1].id, second_workflow); +} + +#[test] +fn channel_workflow_filters_reject_malformed_or_blank_channel_ids() { + for channel_id in ["not-a-uuid", "", " "] { + let error = channel_workflow_filters(vec![channel_id.to_string()]) + .expect_err("malformed channel id must fail before querying the relay"); + assert_eq!(error, "invalid channel id"); + } +} + +#[test] +fn channel_workflow_filters_accepts_empty_input() { + assert_eq!( + channel_workflow_filters(Vec::new()).expect("empty input is valid"), + Vec::::new() + ); +} + #[test] fn trigger_response_uses_persisted_run_id_contract() { let wire = trigger_wire_from_message( From 7f61cf431af1d8f0480a0baf525881a12f2be7f2 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 16:27:37 -0600 Subject: [PATCH 14/16] Preserve managed agent mentions during relay errors (#6167) ## Summary - preserve selected managed-agent `p` tags when fresh managed-directory evidence succeeds but relay discovery or owner-profile lookup fails - keep relay-only agents fail-closed unless fresh relay evidence and any required owner proof are available - cover selective admission with focused unit tests and a signed-event Playwright regression ## Testing - `node --import ./desktop/test-loader.mjs --experimental-strip-types --test desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs` (7 passed) - focused Playwright regression plus adjacent relay-revocation case (2 passed) - pre-commit desktop Biome/file-size hook - pre-push desktop check, TypeScript typecheck, and full desktop unit suite (4,987 passed) Fixes #6147 Signed-off-by: Wes Co-authored-by: Carl --- .../lib/agentMentionRevalidation.test.mjs | 57 +++++++++++++++++++ .../messages/lib/agentMentionRevalidation.ts | 28 +++++---- desktop/tests/e2e/mentions.spec.ts | 46 +++++++++++++++ 3 files changed, 119 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs index 214c9a950fa..d5a57278557 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs @@ -7,6 +7,7 @@ const CURRENT = "a".repeat(64); const AGENT = "b".repeat(64); const HUMAN = "c".repeat(64); const OTHER_OWNER = "d".repeat(64); +const LOCAL_AGENT = "e".repeat(64); function options(refetchOwnerProfiles) { return { @@ -49,6 +50,62 @@ test("owner-only revalidation admits an agent only from a fresh same-owner proof assert.deepEqual(result, [HUMAN, AGENT]); }); +test("fresh managed evidence survives unrelated relay authorization errors", async () => { + const result = await revalidateAgentMentionPubkeys({ + ...options(async () => { + throw new Error("owner profiles unavailable"); + }), + pubkeys: [HUMAN, LOCAL_AGENT], + agentPubkeys: new Set([LOCAL_AGENT]), + refetchManagedAgents: async () => ({ + data: [{ pubkey: LOCAL_AGENT }], + error: null, + }), + refetchRelayAgents: async () => ({ + data: undefined, + error: new Error("relay directory unavailable"), + }), + }); + + assert.deepEqual(result, [HUMAN, LOCAL_AGENT]); +}); + +test("relay-only agents still fail closed when relay discovery fails", async () => { + const result = await revalidateAgentMentionPubkeys({ + ...options(async () => ({ + profiles: { [AGENT]: { ownerPubkey: CURRENT } }, + missing: [], + })), + refetchRelayAgents: async () => ({ + data: undefined, + error: new Error("relay directory unavailable"), + }), + }); + + assert.deepEqual(result, [HUMAN]); +}); + +test("mixed evidence preserves only fresh managed agents and humans", async () => { + const result = await revalidateAgentMentionPubkeys({ + ...options(async () => ({ + profiles: { [AGENT]: { ownerPubkey: CURRENT } }, + missing: [LOCAL_AGENT], + })), + pubkeys: [HUMAN, LOCAL_AGENT, AGENT], + agentPubkeys: new Set([LOCAL_AGENT, AGENT]), + refetchManagedAgents: async () => ({ + data: [{ pubkey: LOCAL_AGENT }], + error: null, + }), + refetchRelayAgents: async () => ({ + data: undefined, + error: new Error("relay directory unavailable"), + }), + }); + + assert.deepEqual(result, [HUMAN, LOCAL_AGENT]); +}); + for (const [name, refetchOwnerProfiles] of [ ["revoked owner proof", async () => ({ profiles: {}, missing: [AGENT] })], [ diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.ts b/desktop/src/features/messages/lib/agentMentionRevalidation.ts index d6c665ba67e..1e6b3a7d669 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.ts +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.ts @@ -57,14 +57,13 @@ export async function revalidateAgentMentionPubkeys({ ? refetchOwnerProfiles([...requestedAgentPubkeys]).catch(() => null) : Promise.resolve(null), ]); + const relayDirectoryReady = + relayResult.error === null && relayResult.data !== undefined; if ( - managedResult.error !== null || - relayResult.error !== null || - managedResult.data === undefined || - relayResult.data === undefined || ownerOnly === undefined || ownerPolicyError !== null || - (ownerOnly && ownerProfiles === null) + managedResult.error !== null || + managedResult.data === undefined ) { return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, new Set()); } @@ -76,23 +75,28 @@ export async function revalidateAgentMentionPubkeys({ currentPubkey, eligibilityScope, managedAgentPubkeys: managedPubkeys, - relayAgents: relayResult.data, + relayAgents: relayDirectoryReady ? relayResult.data : [], sharedChannelIds, }); const admittedPubkeys = new Set( - [...agentPubkeys].filter( - (pubkey) => + [...agentPubkeys].filter((pubkey) => { + const isManagedAgent = managedPubkeys.has(normalizePubkey(pubkey)); + const directoryReady = + isManagedAgent || + (relayDirectoryReady && (!ownerOnly || ownerProfiles !== null)); + return ( getAgentMentionAdmission({ isAgent: true, - isManagedAgent: managedPubkeys.has(pubkey), + isManagedAgent, pubkey, ownerPubkey: ownerProfiles?.profiles[pubkey]?.ownerPubkey, currentPubkey, mentionableAgentPubkeys: mentionablePubkeys, - directoryReady: true, + directoryReady, ownerOnly, - }) === "allow", - ), + }) === "allow" + ); + }), ); return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, admittedPubkeys); } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index fc4dd8c5464..4efd6dd7a3f 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1214,6 +1214,52 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); }); +test("managed agents keep their p tag when relay discovery fails before send", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + status: "running", + }, + ], + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await quinnRow.click(); + await expect(input).toHaveText("@quinn "); + await page.keyboard.type("hello"); + await expect(input).toHaveText("@quinn hello"); + + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill( + "mock unrelated relay directory failure", + ); + }); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); +}); + test("selected relay agents revoked before send emit no p tag", async ({ page, }) => { From c8c8eb58ad5336f21d77e7b02517cd4604a9a7ae Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 17:21:49 -0600 Subject: [PATCH 15/16] chore(release): release Buzz Desktop version 0.5.15 (#6173) ## Buzz Desktop release v0.5.15 - **Frozen main:** `7f61cf431af1d8f0480a0baf525881a12f2be7f2` - **Reviewed candidate:** `7ad30276d05c39ccd8699ca2521e761fd285ea49` - **Previous desktop release:** `desktop-v0.5.14` - **Proposed immutable tag:** `desktop-v0.5.15` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++++++------- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 2c06a06c6a2..e668efa7b2a 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.14", - "base_sha": "1b3dbcaaea882eeea90359c1db02e306d2f4f50a", - "previous_tag": "desktop-v0.5.13", - "previous_base_sha": "09768100ec3420f0aa7cd278bd00fe0baab5de8d", - "previous_merge_sha": "51beba603886d34e751349d12b33c0c5aeb92c28", - "tag": "desktop-v0.5.14", - "commit_count": 1 + "version": "0.5.15", + "base_sha": "7f61cf431af1d8f0480a0baf525881a12f2be7f2", + "previous_tag": "desktop-v0.5.14", + "previous_base_sha": "1b3dbcaaea882eeea90359c1db02e306d2f4f50a", + "previous_merge_sha": "82f7ed1532f50e0d28afca5580ed522f1c2ef1ca", + "tag": "desktop-v0.5.15", + "commit_count": 18 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9248c6fbd77..8b701ec2581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## v0.5.15 + +### Desktop and shared changes + +- Preserve managed agent mentions during relay errors ([#6167](https://github.com/block/buzz/pull/6167)) ([`7f61cf431af1d8f0480a0baf525881a12f2be7f2`](https://github.com/block/buzz/commit/7f61cf431af1d8f0480a0baf525881a12f2be7f2)) +- fix(workflows): preserve multi-channel listing semantics ([#6009](https://github.com/block/buzz/pull/6009)) ([`f7a01bda7b1bf95cdbc9dc21bb69970955b14ecc`](https://github.com/block/buzz/commit/f7a01bda7b1bf95cdbc9dc21bb69970955b14ecc)) +- fix(desktop): align preview sidebar row styling ([#6163](https://github.com/block/buzz/pull/6163)) ([`439c03749182495ee09f85a73423dd17e7ccda61`](https://github.com/block/buzz/commit/439c03749182495ee09f85a73423dd17e7ccda61)) +- fix(desktop): repair dropped team membership links at boot and on edit ([#5904](https://github.com/block/buzz/pull/5904)) ([`57feca2f20bb3434d70ce770b9ed98b1c1472332`](https://github.com/block/buzz/commit/57feca2f20bb3434d70ce770b9ed98b1c1472332)) +- Rename Bumble agent to Pollen ([#5864](https://github.com/block/buzz/pull/5864)) ([`076081bfc646f8fdf8ff9dc6e00843b5bdae0ad0`](https://github.com/block/buzz/commit/076081bfc646f8fdf8ff9dc6e00843b5bdae0ad0)) +- fix(desktop): resolve agent profiles through one archive-aware selector ([#5706](https://github.com/block/buzz/pull/5706)) ([`d12d82577818a95babac4d30cf242c46124feb5e`](https://github.com/block/buzz/commit/d12d82577818a95babac4d30cf242c46124feb5e)) +- feat(workflows): add responsive library card actions ([#6008](https://github.com/block/buzz/pull/6008)) ([`edc4a09aaa41c29e2495a28247c895febaf6587d`](https://github.com/block/buzz/commit/edc4a09aaa41c29e2495a28247c895febaf6587d)) +- fix(desktop): enforce shared agent access across devices ([#6086](https://github.com/block/buzz/pull/6086)) ([`f716eef437dcf91994518b8df7f581e86bb51748`](https://github.com/block/buzz/commit/f716eef437dcf91994518b8df7f581e86bb51748)) +- feat(model-capabilities): drive model capabilities and labels from one manifest ([#5597](https://github.com/block/buzz/pull/5597)) ([`1b7e5ac1be641f5ecc2b2a0ba37a1dc400e073c9`](https://github.com/block/buzz/commit/1b7e5ac1be641f5ecc2b2a0ba37a1dc400e073c9)) +- fix(desktop): hide the offcanvas-collapsed sidebar so it stops painting over the community rail ([#5947](https://github.com/block/buzz/pull/5947)) ([`78cbffeb64c01220e705adf0aa9690fdbd0d7a37`](https://github.com/block/buzz/commit/78cbffeb64c01220e705adf0aa9690fdbd0d7a37)) + +### Other repository changes + +- Remove Startup Recovery section in base prompt ([#6161](https://github.com/block/buzz/pull/6161)) ([`f64899e5d17df4c928ea415a5f42052120edaecb`](https://github.com/block/buzz/commit/f64899e5d17df4c928ea415a5f42052120edaecb)) +- fix(cli): keep project replacement timestamps at or after wall clock ([#5666](https://github.com/block/buzz/pull/5666)) ([`a282e0643fe0f14ace4d9b57ead99d0635e38995`](https://github.com/block/buzz/commit/a282e0643fe0f14ace4d9b57ead99d0635e38995)) +- Remove GitHub security advisory commitment ([#6144](https://github.com/block/buzz/pull/6144)) ([`85bacea52b8359999f22c6ac07207a130809c488`](https://github.com/block/buzz/commit/85bacea52b8359999f22c6ac07207a130809c488)) +- fix(acp): gate relay-signed workflow messages on their attributed author ([#6129](https://github.com/block/buzz/pull/6129)) ([`54f11219efe6b2617ba74d1ef8701fb5413956d8`](https://github.com/block/buzz/commit/54f11219efe6b2617ba74d1ef8701fb5413956d8)) +- fix(acp): replace Goose native system prompt ([#5964](https://github.com/block/buzz/pull/5964)) ([`5b3f0375a26843d73b29b55cc2f3c313bd857ccb`](https://github.com/block/buzz/commit/5b3f0375a26843d73b29b55cc2f3c313bd857ccb)) +- docs: refresh agent development guidance ([#6049](https://github.com/block/buzz/pull/6049)) ([`f956e6fe06a76e50cbd8fba1a162482e752e7f1a`](https://github.com/block/buzz/commit/f956e6fe06a76e50cbd8fba1a162482e752e7f1a)) +- feat(mobile): require device authentication for identity export ([#5116](https://github.com/block/buzz/pull/5116)) ([`d8281b9c93395f15d55091b131bb2747a0a3da8a`](https://github.com/block/buzz/commit/d8281b9c93395f15d55091b131bb2747a0a3da8a)) +- Polish mobile message threads and composer ([#5645](https://github.com/block/buzz/pull/5645)) ([`69107dc3bfecbb80cc5f5b8bb6a7647ad054ce57`](https://github.com/block/buzz/commit/69107dc3bfecbb80cc5f5b8bb6a7647ad054ce57)) + +[Compare desktop-v0.5.14...desktop-v0.5.15](https://github.com/block/buzz/compare/desktop-v0.5.14...desktop-v0.5.15) + ## v0.5.14 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 39e93d8a98d..963fe643d63 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.14", + "version": "0.5.15", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 887e1282ffa..7d5a3f67dcb 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.14" +version = "0.5.15" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 527690df14f..6d14b04cf4c 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.14" +version = "0.5.15" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2f85c5d5172..dd7ab08e06d 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.14", + "version": "0.5.15", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From f8692fa9b52ddcfeb4b95fb4862109983509f131 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 17:47:07 -0600 Subject: [PATCH 16/16] test(desktop): cover exact workflow batch limit (#6168) ## Summary - retain explicit regression coverage for the exact 128-channel relay request limit - cover the 129-channel split into 128 + 1 filters The workflow-listing implementation originally carried by this PR landed through #6009. This branch is now rebased onto current `main`, so the remaining diff is only the boundary test that #6009 did not include. Fixes #6116 ## Test plan - `cargo test --manifest-path desktop/src-tauri/Cargo.toml workflow_queries_respect_relay_explicit_channel_limit` - pre-push hook: Desktop checks, Desktop tests, Desktop Tauri checks, and path-scoped Rust tests Signed-off-by: Wes Co-authored-by: Carl --- .../src-tauri/src/commands/workflows_tests.rs | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/desktop/src-tauri/src/commands/workflows_tests.rs b/desktop/src-tauri/src/commands/workflows_tests.rs index 8522d233c38..6523d458629 100644 --- a/desktop/src-tauri/src/commands/workflows_tests.rs +++ b/desktop/src-tauri/src/commands/workflows_tests.rs @@ -216,18 +216,24 @@ fn multi_channel_workflow_query_uses_one_filter_per_channel() { } #[test] -fn workflow_queries_batch_above_relay_explicit_channel_limit() { - let channel_ids = (0..WORKFLOW_QUERY_CHANNEL_BATCH_SIZE + 1) - .map(|index| uuid::Uuid::from_u128(index as u128 + 1).to_string()) - .collect(); - let batches = channel_workflow_filter_batches(channel_ids).expect("valid channels"); +fn workflow_queries_respect_relay_explicit_channel_limit() { + for (channel_count, expected_batch_sizes) in [ + (WORKFLOW_QUERY_CHANNEL_BATCH_SIZE, vec![128]), + (WORKFLOW_QUERY_CHANNEL_BATCH_SIZE + 1, vec![128, 1]), + ] { + let channel_ids = (0..channel_count) + .map(|index| uuid::Uuid::from_u128(index as u128 + 1).to_string()) + .collect(); + let batches = channel_workflow_filter_batches(channel_ids).expect("valid channels"); - assert_eq!(batches.len(), 2); - assert_eq!(batches[0].len(), WORKFLOW_QUERY_CHANNEL_BATCH_SIZE); - assert_eq!(batches[1].len(), 1); - assert!(batches.iter().flatten().all(|filter| filter["#h"] - .as_array() - .is_some_and(|values| values.len() == 1))); + assert_eq!( + batches.iter().map(Vec::len).collect::>(), + expected_batch_sizes + ); + assert!(batches.iter().flatten().all(|filter| filter["#h"] + .as_array() + .is_some_and(|values| values.len() == 1))); + } } #[test]