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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/crates/adapters/opencode-adapter/src/agent_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,21 @@ impl ExternalSubagentSourceProvider for OpenCodeSubagentProvider {

for layer in self.discover_layers(&input.context)? {
let source_key = source_key(&layer);
if input.suppressed_sources.contains(&source_key) {
let suppressed = input.suppressed_sources.contains(&source_key);
if suppressed {
sources.push(ExternalSourceRecord {
key: source_key,
ecosystem_id: EcosystemId::new(ECOSYSTEM_ID)
.expect("static OpenCode ecosystem id must be valid"),
display_name: layer.display_name.clone(),
source_kind: layer.source_kind().to_string(),
scope: layer.scope,
location: layer.path.to_string_lossy().to_string(),
execution_domain_id: input.context.execution_domain_id.clone(),
health: ExternalSourceHealth::Available,
content_version: digest([layer.path.to_string_lossy().as_ref()]),
diagnostics: Vec::new(),
});
continue;
}
let parsed = parse_layer(&layer)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,41 @@ fn global_and_project_agent_fields_deep_merge_with_ordered_provenance() {
assert_eq!(without_project.definitions[0].provenance.len(), 1);
}

#[test]
fn suppressed_agent_source_remains_discoverable_for_reenable() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().join("workspace");
fs::create_dir_all(workspace.join(".git")).unwrap();
fs::create_dir_all(temp.path().join("user")).unwrap();
fs::write(
temp.path().join("user/opencode.json"),
r#"{
"agent": {
"review": {
"description": "Review agent",
"prompt": "Review the change",
"mode": "subagent"
}
}
}"#,
)
.unwrap();

let provider = provider(&temp, &workspace);
let initial = discover(&provider, workspace.clone(), BTreeSet::new());
let source_key = initial.sources[0].key.clone();
fs::write(temp.path().join("user/opencode.json"), "{ invalid").unwrap();
let suppressed = discover(
&provider,
workspace,
[source_key.clone()].into_iter().collect(),
);

assert!(suppressed.definitions.is_empty());
assert_eq!(suppressed.sources.len(), 1);
assert_eq!(suppressed.sources[0].key, source_key);
}

#[test]
fn safe_subset_is_fail_closed_and_default_tools_are_explicit() {
let temp = TempDir::new().unwrap();
Expand Down
1 change: 1 addition & 0 deletions src/crates/assembly/core/src/external_mcp_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ fn snapshot(behavior_version: &str) -> ExternalMcpCoordinatorSnapshot {
discovery_pending: false,
sources: vec![ExternalSourceCatalogEntry {
stable_key: source.preference_key(),
presentation_group_id: None,
record: source,
lifecycle: ExternalSourceLifecycleState::Available,
}],
Expand Down
128 changes: 127 additions & 1 deletion src/crates/assembly/core/src/external_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1176,6 +1176,7 @@ impl WorkspaceExternalSourceService {
}
policy = integration_policy_snapshot(&preferences, self.workspace_root.as_deref())?;
snapshot.integration_policy = policy;
assign_external_source_presentation_groups(&mut snapshot);
sanitize_external_snapshot_locations(&mut snapshot, self.workspace_root.as_deref());
let mut current = lock_snapshot(&self.snapshot);
let mcp_changed = snapshot.mcp_servers != current.mcp_servers
Expand Down Expand Up @@ -1296,6 +1297,7 @@ impl WorkspaceExternalSourceService {
}
}
snapshot.integration_policy = policy;
assign_external_source_presentation_groups(&mut snapshot);
sanitize_external_snapshot_locations(&mut snapshot, self.workspace_root.as_deref());
let mut current = lock_snapshot(&self.snapshot);
let mcp_changed = snapshot.mcp_servers != current.mcp_servers
Expand Down Expand Up @@ -2078,7 +2080,7 @@ impl WorkspaceExternalSourceService {
enabled: bool,
expected_preference_revision: u64,
) -> Result<ExternalSourceCatalogSnapshot, String> {
let _refresh_guard = self.refresh_gate.lock().await;
let refresh_guard = self.refresh_gate.lock().await;
if self.snapshot().preference_revision != expected_preference_revision {
return Err(stale_operation_error(
"External source preferences changed; refresh before retrying",
Expand Down Expand Up @@ -2138,6 +2140,10 @@ impl WorkspaceExternalSourceService {
lock_mcp_coordinator(&self.mcp_coordinator)
.replace_suppressed_sources(authoritative.clone());
propagate_suppressed_sources(&authoritative, self);
// Refresh acquires the same gate. Release the mutation critical section
// after the preference and in-memory coordinators agree, then refresh
// from the authoritative store to avoid self-deadlocking the request.
drop(refresh_guard);
self.refresh_preserving_worker_recovery().await
}

Expand Down Expand Up @@ -3220,6 +3226,47 @@ pub(super) fn safe_external_source_location(
}
}

fn assign_external_source_presentation_groups(snapshot: &mut ExternalSourceCatalogSnapshot) {
let mut groups = BTreeMap::<(String, String, String), Vec<usize>>::new();
for (index, source) in snapshot.sources.iter().enumerate() {
let normalized_location = source
.record
.location
.trim()
.replace('\\', "/")
.trim_end_matches('/')
.to_string();
let location_key = if normalized_location.is_empty() {
format!("<source:{}>", source.stable_key)
} else {
normalized_location
};
groups
.entry((
source.record.ecosystem_id.as_str().to_string(),
source.record.execution_domain_id.as_str().to_string(),
location_key,
))
.or_default()
.push(index);
}

for indices in groups.into_values() {
let mut stable_keys = indices
.iter()
.map(|index| snapshot.sources[*index].stable_key.as_str())
.collect::<Vec<_>>();
stable_keys.sort_unstable();
let group_id = format!(
"external-source:{}",
serde_json::to_string(&stable_keys).unwrap_or_default()
);
for index in indices {
snapshot.sources[index].presentation_group_id = Some(group_id.clone());
}
}
}

fn sanitize_external_snapshot_locations(
snapshot: &mut ExternalSourceCatalogSnapshot,
workspace_root: Option<&Path>,
Expand Down Expand Up @@ -5223,6 +5270,7 @@ mod tests {
discovery_pending: false,
sources: vec![ExternalSourceCatalogEntry {
stable_key: source_key.stable_key(),
presentation_group_id: None,
record: ExternalSourceRecord {
key: source_key.clone(),
ecosystem_id: EcosystemId::new("future-ai").unwrap(),
Expand Down Expand Up @@ -5287,6 +5335,84 @@ mod tests {
assert!(!serialized.contains("C:/Users/alice"));
}

#[test]
fn presentation_groups_are_assigned_before_location_redaction() {
let make_source = |provider_id: &str, stable_key: &str, location: &str| {
let source_key = SourceKey::new(provider_id, "user-configuration").unwrap();
ExternalSourceCatalogEntry {
stable_key: stable_key.to_string(),
presentation_group_id: None,
record: ExternalSourceRecord {
key: source_key,
ecosystem_id: EcosystemId::new("opencode").unwrap(),
display_name: "OpenCode user configuration".to_string(),
source_kind: "configuration".to_string(),
scope: ExternalSourceScope::RemoteUser,
location: location.to_string(),
execution_domain_id: ExecutionDomainId::new("peer-a").unwrap(),
health:
bitfun_product_domains::external_sources::ExternalSourceHealth::Available,
content_version: "source-v1".to_string(),
diagnostics: Vec::new(),
},
lifecycle: ExternalSourceLifecycleState::Available,
}
};
let mut snapshot = ExternalSourceCatalogSnapshot {
generation: 0,
discovery_pending: false,
sources: vec![
make_source(
"opencode.commands",
"command-source",
"/remote/alice/.config/opencode/opencode.json",
),
make_source(
"opencode.subagents",
"agent-source",
"/remote/alice/.config/opencode/opencode.json",
),
make_source(
"opencode.mcp",
"other-user-source",
"/remote/bob/.config/opencode/opencode.json",
),
],
commands: Vec::new(),
command_conflicts: Vec::new(),
tools: Vec::new(),
tool_approval_requests: Vec::new(),
tool_conflicts: Vec::new(),
mcp_generation: 0,
mcp_servers: Vec::new(),
mcp_approval_requests: Vec::new(),
mcp_conflicts: Vec::new(),
subagent_generation: 0,
preference_revision: 0,
subagents: Vec::new(),
subagent_conflicts: Vec::new(),
pending_subagent_approvals: Vec::new(),
integration_policy: Default::default(),
diagnostics: Vec::new(),
};

assign_external_source_presentation_groups(&mut snapshot);
sanitize_external_snapshot_locations(&mut snapshot, None);

assert_eq!(
snapshot.sources[0].presentation_group_id,
snapshot.sources[1].presentation_group_id,
);
assert_ne!(
snapshot.sources[0].presentation_group_id,
snapshot.sources[2].presentation_group_id,
);
assert!(snapshot
.sources
.iter()
.all(|source| source.record.location == "<remote>/.config/opencode/opencode.json"));
}

struct DelayedProvider {
identity: PromptCommandProviderIdentity,
source: SourceKey,
Expand Down
1 change: 1 addition & 0 deletions src/crates/assembly/core/src/external_subagents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,7 @@ mod tests {
)]),
sources: vec![ExternalSourceCatalogEntry {
stable_key: "source".to_string(),
presentation_group_id: None,
record: ExternalSourceRecord {
key: source,
ecosystem_id: EcosystemId::new("fake").unwrap(),
Expand Down
2 changes: 2 additions & 0 deletions src/crates/assembly/external-sources/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ impl ExternalSourceCoordinator {
}
sources.push(ExternalSourceCatalogEntry {
stable_key,
presentation_group_id: None,
record: record.clone(),
lifecycle,
});
Expand Down Expand Up @@ -612,6 +613,7 @@ impl ExternalSourceCoordinator {
sources.extend(self.removed_sources.iter().map(|(stable_key, record)| {
ExternalSourceCatalogEntry {
stable_key: stable_key.clone(),
presentation_group_id: None,
record: record.clone(),
lifecycle: ExternalSourceLifecycleState::Removed,
}
Expand Down
1 change: 1 addition & 0 deletions src/crates/assembly/external-sources/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ impl ExternalMcpCoordinator {
let suppressed = self.suppressed_sources.contains(&source.preference_key());
sources.push(ExternalSourceCatalogEntry {
stable_key: source.preference_key(),
presentation_group_id: None,
record: source.clone(),
lifecycle: source_lifecycle(source.health, suppressed, failed),
});
Expand Down
1 change: 1 addition & 0 deletions src/crates/assembly/external-sources/src/subagent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ impl ExternalSubagentCoordinator {
let suppressed = suppressed_keys.contains(&source.key);
sources.push(ExternalSourceCatalogEntry {
stable_key: source.preference_key(),
presentation_group_id: None,
record: source.clone(),
lifecycle: if suppressed {
ExternalSourceLifecycleState::Suppressed
Expand Down
1 change: 1 addition & 0 deletions src/crates/assembly/external-sources/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ impl ExternalToolCoordinator {
let suppressed = self.suppressed_sources.contains(&source.preference_key());
sources.push(ExternalSourceCatalogEntry {
stable_key: source.preference_key(),
presentation_group_id: None,
record: source.clone(),
lifecycle: if suppressed {
ExternalSourceLifecycleState::Suppressed
Expand Down
5 changes: 5 additions & 0 deletions src/crates/contracts/product-domains/src/external_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1458,6 +1458,11 @@ pub enum ExternalSourceLifecycleState {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExternalSourceCatalogEntry {
pub stable_key: String,
/// Opaque identity shared by provider records that describe the same
/// physical source. Product surfaces may coalesce matching entries without
/// comparing redacted display locations.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presentation_group_id: Option<String>,
pub record: ExternalSourceRecord,
pub lifecycle: ExternalSourceLifecycleState,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ use bitfun_product_domains::external_sources::{
ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict,
ExternalMcpConflictCandidate, ExternalMcpDiscoveryInput, ExternalMcpProviderIdentity,
ExternalMcpProviderSnapshot, ExternalMcpServerDefinition, ExternalMcpStaticStatus,
ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot,
ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth,
ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord,
ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind,
ExternalMcpTransportKind, ExternalSourceAssetKind, ExternalSourceCatalogEntry,
ExternalSourceCatalogSnapshot, ExternalSourceContext, ExternalSourceDiagnostic,
ExternalSourceHealth, ExternalSourceLifecycleState, ExternalSourceProviderError,
ExternalSourcePublicSnapshot, ExternalSourceRecord, ExternalSourceScope,
ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind,
ExternalToolStaticStatus, ExternalWatchRoot, PreparedExternalMcpServer,
PreparedExternalMcpTransport, PromptCommandAvailability, PromptCommandCatalogEntry,
PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot,
Expand Down Expand Up @@ -100,6 +101,28 @@ fn source_and_command_identity_remain_provider_qualified() {
assert_ne!(left.stable_key(), right.stable_key());
}

#[test]
fn presentation_group_id_is_optional_and_uses_the_camel_case_wire_name() {
let mut entry = ExternalSourceCatalogEntry {
stable_key: "opencode.commands:project".to_string(),
presentation_group_id: None,
record: source("opencode.commands", "opencode", "project"),
lifecycle: ExternalSourceLifecycleState::Available,
};

let legacy_value = serde_json::to_value(&entry).unwrap();
assert!(legacy_value.get("presentationGroupId").is_none());
let legacy_entry: ExternalSourceCatalogEntry = serde_json::from_value(legacy_value).unwrap();
assert!(legacy_entry.presentation_group_id.is_none());

entry.presentation_group_id = Some("external-source:[\"source\"]".to_string());
let current_value = serde_json::to_value(&entry).unwrap();
assert_eq!(
current_value["presentationGroupId"],
"external-source:[\"source\"]"
);
}

#[test]
fn conflict_fingerprint_is_order_independent_and_changes_with_content() {
let first = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v2")]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export interface ExternalSourceCatalogSnapshot {
discoveryPending: boolean;
sources: Array<{
stableKey: string;
presentationGroupId?: string;
record: ExternalSourceRecord;
lifecycle: ExternalSourceLifecycle;
}>;
Expand Down
Loading
Loading