diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index 52ec447e0e..2357a2022c 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -86,6 +86,7 @@ struct ResolvedExternalCandidate { model_label: String, model_configuration_fingerprint: String, tools: Vec, + unavailable_tool_labels: Vec, readonly: bool, activation_envelope: String, approval_key: String, @@ -675,6 +676,7 @@ fn resolve_external_candidate( }; let mut tools = Vec::new(); + let mut unavailable_tool_labels = Vec::new(); for selector in definition .requested_tools .selectors @@ -689,6 +691,7 @@ fn resolve_external_candidate( Some(tool) => tools.push(tool.clone()), None => { compatibility = ExternalSubagentCompatibilityState::Blocked; + unavailable_tool_labels.push(name.to_string()); diagnostics.push(ExternalSubagentDiagnosticSummary { code: "external_subagent.tool_unavailable".to_string(), blocks_activation: true, @@ -698,6 +701,8 @@ fn resolve_external_candidate( } tools.sort_by(|left, right| left.name.cmp(&right.name)); tools.dedup_by(|left, right| left.name == right.name); + unavailable_tool_labels.sort(); + unavailable_tool_labels.dedup(); diagnostics.sort_by(|left, right| left.code.cmp(&right.code)); diagnostics.dedup_by(|left, right| left.code == right.code); let readonly = tools.iter().all(|tool| tool.readonly); @@ -779,6 +784,7 @@ fn resolve_external_candidate( model_label: model.display_label, model_configuration_fingerprint: model.configuration_fingerprint, tools, + unavailable_tool_labels, readonly, activation_envelope, approval_key, @@ -1002,6 +1008,7 @@ fn summary_for( .iter() .map(|tool| tool.name.clone()) .collect(), + unavailable_tool_labels: candidate.unavailable_tool_labels.clone(), supports_follow_up: false, compatibility_state: candidate.compatibility, diagnostics: candidate.diagnostics.clone(), @@ -1419,6 +1426,40 @@ mod tests { assert_eq!(recovered.registrations.len(), 1); } + #[test] + fn unavailable_tool_labels_are_preserved_for_product_diagnostics() { + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let mut definition_snapshot = snapshot("behavior-v1", "catalog-v1"); + definition_snapshot.definitions[0] + .requested_tools + .selectors + .push(ExternalSubagentToolSelector { + source_name: "shell".to_string(), + canonical_host_name: Some("Shell".to_string()), + allowed: true, + }); + + let state = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &definition_snapshot, + ExternalSubagentDecisions { + active_ecosystems: test_active_ecosystems(), + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &facts(), + ); + + assert_eq!(state.summaries[0].unavailable_tool_labels, ["Shell"]); + assert!(state.summaries[0].diagnostics.iter().any(|diagnostic| { + diagnostic.code == "external_subagent.tool_unavailable" && diagnostic.blocks_activation + })); + } + #[test] fn model_config_outage_logging_is_deduplicated_and_does_not_expose_error_values() { let logged = AtomicBool::new(false); diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index 712c489c51..d3c90b732b 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -1912,6 +1912,9 @@ impl ExternalSourcePublicSnapshot { tool.activation = ExternalToolActivationState::Disabled; } } + for subagent in &mut self.subagents { + subagent.unavailable_tool_labels.clear(); + } self } } diff --git a/src/crates/contracts/product-domains/src/external_subagents.rs b/src/crates/contracts/product-domains/src/external_subagents.rs index 03920129c0..6ce8a8562e 100644 --- a/src/crates/contracts/product-domains/src/external_subagents.rs +++ b/src/crates/contracts/product-domains/src/external_subagents.rs @@ -516,6 +516,8 @@ pub struct ExternalSubagentSummary { #[serde(default, skip_serializing_if = "Option::is_none")] pub effective_model_label: Option, pub effective_tool_labels: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unavailable_tool_labels: Vec, pub supports_follow_up: bool, pub compatibility_state: ExternalSubagentCompatibilityState, pub diagnostics: Vec, diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index 9027d8a3bf..65643c45a0 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -584,6 +584,27 @@ fn legacy_public_snapshot_downprojects_new_tool_review_variants() { "approvalKey": "approval-v1", "decisionKey": "decision-v1", "activation": { "state": "declined" } + }], + "subagents": [{ + "candidateId": "external-review", + "logicalId": "review", + "displayName": "External Review", + "description": "Review changes", + "providerLabel": "OpenCode", + "scope": "project", + "sourceKeys": [], + "sourceLocationLabels": [], + "sourceCount": 1, + "effectiveToolLabels": ["Read"], + "unavailableToolLabels": ["Shell"], + "supportsFollowUp": false, + "compatibilityState": "blocked", + "diagnostics": [{ + "code": "external_subagent.tool_unavailable", + "blocksActivation": true + }], + "activationState": { "state": "blocked" }, + "decisionKey": "agent-decision-v1" }] })) .expect("new public snapshot"); @@ -591,6 +612,9 @@ fn legacy_public_snapshot_downprojects_new_tool_review_variants() { let legacy = serde_json::to_value(snapshot.into_legacy_v0_compatible()).expect("legacy public snapshot"); assert_eq!(legacy["tools"][0]["activation"]["state"], "disabled"); + assert!(legacy["subagents"][0] + .get("unavailableToolLabels") + .is_none()); } #[test] diff --git a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts index bae5c3a45d..d7932df282 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ExternalSourcesAPI.ts @@ -211,6 +211,7 @@ export interface ExternalSubagentSummary { sourceCount: number; effectiveModelLabel?: string; effectiveToolLabels: string[]; + unavailableToolLabels: string[]; supportsFollowUp: boolean; compatibilityState: 'ready' | 'ready_with_degradation' | 'blocked' | 'invalid'; diagnostics: Array<{ code: string; blocksActivation: boolean }>; @@ -775,6 +776,7 @@ function normalizeSnapshot(value: unknown): ExternalSourceCatalogSnapshot { sourceKeys: normalizeOptionalArray(subagent.sourceKeys), sourceLocationLabels: normalizeOptionalArray(subagent.sourceLocationLabels), effectiveToolLabels: normalizeOptionalArray(subagent.effectiveToolLabels), + unavailableToolLabels: normalizeOptionalArray(subagent.unavailableToolLabels), diagnostics: normalizeOptionalArray(subagent.diagnostics), })), subagentConflicts: normalizeOptionalArray(candidate.subagentConflicts).map((conflict) => ({ diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index e52988c3a0..79e294aa86 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -494,6 +494,41 @@ font-size: 11px; } + &__review-summary { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + color: var(--color-text-secondary); + font-size: 12px; + } + + &__review-risk { + margin-top: 6px; + } + + &__review-details { + margin-top: 8px; + color: var(--color-text-secondary); + font-size: 12px; + + > summary { + width: fit-content; + color: var(--color-accent-500); + cursor: pointer; + user-select: none; + } + + &[open] > summary { + margin-bottom: 8px; + } + } + + &__diagnostic-code { + color: var(--color-text-muted); + font-size: 11px; + overflow-wrap: anywhere; + } + &__tool-actions { display: flex; justify-content: flex-end; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 7c3b06cbd1..5490b52231 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -726,6 +726,27 @@ describe('ExternalSourcesConfig', () => { behaviorVersion: 'behavior-v1', staticStatus: { state: 'ready' }, }, + }, { + candidateId: 'external-mcp-docs', + approvalKey: 'mcp-approval-v2', + decisionKey: 'mcp-decision-v2', + definition: { + id: { + source: { providerId: 'opencode.mcp', sourceId: 'project' }, + localId: 'docs', + }, + provenance: [{ providerId: 'opencode.mcp', sourceId: 'project' }], + name: 'docs', + transport: 'streamable_http', + remoteUrlPreview: 'https://mcp.example.test', + argumentCount: 0, + environmentKeys: [], + environmentReferenceNames: [], + headerNames: [], + sourceEnabled: true, + behaviorVersion: 'behavior-v2', + staticStatus: { state: 'ready' }, + }, }], mcpConflicts: [{ conflictKey: 'mcp-conflict-v1', @@ -771,6 +792,45 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('GITHUB_TOKEN'); expect(container.textContent).toContain('OPENCODE_TOKEN'); + const approvalDetails = container.querySelector( + '.bitfun-external-sources-config__review-details', + ) as HTMLDetailsElement; + const approvalCard = approvalDetails.closest( + '.bitfun-external-sources-config__tool-card', + ) as HTMLElement; + const alwaysVisibleSummary = approvalCard.querySelector( + '.bitfun-external-sources-config__review-summary', + ) as HTMLElement; + const alwaysVisibleRisk = approvalCard.querySelector( + '.bitfun-external-sources-config__review-risk', + ) as HTMLElement; + expect(approvalDetails.open).toBe(false); + expect(alwaysVisibleSummary.textContent).toContain('mcp.command:{"command":"npx"}'); + expect(alwaysVisibleRisk.textContent).toContain('mcpApprovals.compactWarning'); + expect(approvalDetails.contains(alwaysVisibleSummary)).toBe(false); + expect(approvalDetails.contains(alwaysVisibleRisk)).toBe(false); + const approvalEnable = Array.from(approvalCard.querySelectorAll('button')).find((button) => + button.textContent?.includes('mcpApprovals.enable')) as HTMLButtonElement; + expect(alwaysVisibleRisk.id).toBe('mcp-review-risk-mcp-decision-v1'); + expect(approvalEnable.getAttribute('aria-describedby')).toBe(alwaysVisibleRisk.id); + const remoteSummary = Array.from(approvalCard.parentElement?.querySelectorAll( + '.bitfun-external-sources-config__review-summary', + ) ?? []).find((candidate) => candidate.textContent?.includes('mcp.url')) as HTMLElement; + const remoteDetails = remoteSummary.closest( + '.bitfun-external-sources-config__tool-card', + )?.querySelector('.bitfun-external-sources-config__review-details') as HTMLDetailsElement; + expect(remoteSummary.textContent).toContain( + 'mcp.url:{"url":"https://mcp.example.test"}', + ); + expect(remoteDetails.textContent).not.toContain( + 'mcp.url:{"url":"https://mcp.example.test"}', + ); + expect(approvalDetails.querySelector('summary')?.textContent) + .toContain('mcpApprovals.showDetails'); + expect(container.textContent).toContain('mcpApprovals.enable'); + await act(async () => approvalDetails.querySelector('summary')?.click()); + expect(approvalDetails.open).toBe(true); + const externalConflictCandidate = Array.from( container.querySelectorAll('.bitfun-external-sources-config__candidate'), ).find((candidate) => candidate.textContent?.includes('OpenCode: github')); @@ -1256,14 +1316,24 @@ describe('ExternalSourcesConfig', () => { sourceCount: 1, effectiveModelLabel: 'fast', effectiveToolLabels: ['Read', 'Grep'], + unavailableToolLabels: ['Shell', 'Write'], supportsFollowUp: false, compatibilityState: 'ready', diagnostics: [{ code: 'opencode_agent_prompt_not_imported', blocksActivation: true, + }, { + code: 'external_subagent.tool_unavailable', + blocksActivation: true, + }, { + code: 'opencode_agent_permission_not_imported', + blocksActivation: true, }, { code: 'opencode_default_permission_semantics_not_imported', blocksActivation: false, + }, { + code: 'opencode_agent_temperature_not_imported', + blocksActivation: false, }, { code: 'opencode_agent_definition_type_invalid', blocksActivation: true, @@ -1340,9 +1410,21 @@ describe('ExternalSourcesConfig', () => { expect(container.textContent).toContain('fast'); expect(container.textContent).toContain('Read, Grep'); expect(container.textContent).toContain('agents.executionDomain'); - expect(container.textContent).toContain('agentDiagnostics.unsupportedBehavior.reason'); - expect(container.textContent).toContain('agentDiagnostics.ignoredOption.reason'); + expect(container.textContent).toContain( + 'agentDiagnostics.toolUnavailable.reason:{"tools":"Shell, Write"}', + ); + expect(container.textContent).toContain('agentDiagnostics.promptMissing.reason'); + expect(container.textContent).toContain( + 'agentDiagnostics.unsupportedSetting.reason:{"setting":"agentDiagnostics.settings.permissions"}', + ); + expect(container.textContent).toContain( + 'agentDiagnostics.ignoredSetting.reason:{"setting":"agentDiagnostics.settings.defaultPermissions"}', + ); + expect(container.textContent).toContain( + 'agentDiagnostics.ignoredSetting.reason:{"setting":"agentDiagnostics.settings.temperature"}', + ); expect(container.textContent).toContain('agentDiagnostics.invalidDefinition.reason'); + expect(container.textContent).toContain('opencode_agent_definition_type_invalid'); expect(container.textContent).toContain('agentConflicts.selectionApproves'); expect(container.textContent).toContain('.opencode/agents/explore.md'); expect(container.textContent).not.toContain('D:/workspace/project/.opencode/agents'); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index f3ebc94e47..265b5dca8b 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -61,6 +61,25 @@ const SOURCE_COUNT_LABELS = [ ['mcps', 'sources.mcpCount'], ] as const; +const AGENT_DIAGNOSTIC_SETTING_KEYS: Record = { + opencode_unknown_agent_field: 'unknownField', + opencode_ambient_permission_not_imported: 'ambientPermissions', + opencode_agent_permission_not_imported: 'permissions', + opencode_agent_options_not_imported: 'options', + opencode_native_agent_overlay_not_imported: 'nativeAgentOverlay', + opencode_legacy_primary_mode_not_imported: 'legacyPrimaryMode', + opencode_primary_agent_not_imported: 'primaryAgentMode', + opencode_agent_tool_pattern_not_imported: 'toolPatterns', + opencode_default_permission_semantics_not_imported: 'defaultPermissions', + opencode_agent_variant_not_imported: 'variant', + opencode_agent_temperature_not_imported: 'temperature', + opencode_agent_top_p_not_imported: 'topP', + opencode_agent_steps_not_imported: 'steps', + opencode_agent_maxSteps_not_imported: 'maxSteps', + opencode_agent_color_not_imported: 'color', + opencode_primary_facet_not_imported: 'primaryFacet', +}; + type SnapshotLoadResult = | { status: 'accepted'; snapshot: ExternalSourceCatalogSnapshot } | { status: 'ignored' } @@ -84,6 +103,10 @@ function agentDiagnosticCategory(code: string, blocksActivation: boolean): strin if (code.includes('configuration_unavailable')) return 'configurationUnavailable'; if (code.includes('model_unavailable')) return 'modelUnavailable'; if (code.includes('tool_unavailable')) return 'toolUnavailable'; + if (code === 'opencode_agent_prompt_not_imported') return 'promptMissing'; + if (AGENT_DIAGNOSTIC_SETTING_KEYS[code]) { + return blocksActivation ? 'unsupportedSetting' : 'ignoredSetting'; + } if (code.includes('type_invalid') || code.includes('definition_invalid') || code.endsWith('_invalid')) { return 'invalidDefinition'; @@ -91,6 +114,23 @@ function agentDiagnosticCategory(code: string, blocksActivation: boolean): strin return blocksActivation ? 'unsupportedBehavior' : 'ignoredOption'; } +function agentDiagnosticParams( + code: string, + category: string, + unavailableToolLabels: string[], + t: TFunction, +): Record | undefined { + if (category === 'toolUnavailable') { + return { + tools: unavailableToolLabels.join(', ') || t('agents.unavailableToolsUnknown'), + }; + } + const settingKey = AGENT_DIAGNOSTIC_SETTING_KEYS[code]; + return settingKey + ? { setting: t(`agentDiagnostics.settings.${settingKey}`) } + : undefined; +} + function sourceDiagnosticCategory(code: string): string { if (code.includes('preference_read_failed')) return 'confirmationStateUnavailable'; if (code.includes('conflict_history_write_failed')) return 'conflictHistoryUnavailable'; @@ -1801,6 +1841,7 @@ const ExternalSourcesConfig: React.FC = () => { candidate.record.key.providerId === request.definition.id.source.providerId && candidate.record.key.sourceId === request.definition.id.source.sourceId )); + const reviewRiskId = `mcp-review-risk-${encodeURIComponent(request.decisionKey)}`; return (
{
{request.definition.name}
-
+
{t('mcp.source', { source: source?.record.displayName ?? t('mcp.externalSource'), })} - {source ? ( - {t('mcp.sourceLocation', { - location: source.record.location, - })} - ) : null} - {source ? ( - {t('mcp.scope', { - scope: sourceScopeLabel(source.record.scope, t), - })} - ) : null} {t(`mcp.transport.${request.definition.transport}`)} {request.definition.commandPreview ? ( {t('mcp.command', { command: request.definition.commandPreview })} @@ -1830,39 +1861,64 @@ const ExternalSourcesConfig: React.FC = () => { {request.definition.remoteUrlPreview ? ( {t('mcp.url', { url: request.definition.remoteUrlPreview })} ) : null} - {request.definition.workingDirectory ? ( - {t('mcp.workingDirectory', { - location: request.definition.workingDirectory, - })} - ) : null} - {t('mcp.argumentCount', { - count: request.definition.argumentCount, - })} - {t('mcp.environmentCount', { - count: request.definition.environmentKeys.length, - })} - {request.definition.environmentKeys.length > 0 ? ( - {t('mcp.environmentNames', { - names: request.definition.environmentKeys.join(', '), +
+
+ {t('mcpApprovals.compactWarning')} +
+
+ {t('mcpApprovals.showDetails')} +
+ {source ? ( + {t('mcp.sourceLocation', { + location: source.record.location, + })} + ) : null} + {source ? ( + {t('mcp.scope', { + scope: sourceScopeLabel(source.record.scope, t), + })} + ) : null} + {request.definition.workingDirectory ? ( + {t('mcp.workingDirectory', { + location: request.definition.workingDirectory, + })} + ) : null} + {t('mcp.argumentCount', { + count: request.definition.argumentCount, })} - ) : null} - {(request.definition.environmentReferenceNames?.length ?? 0) > 0 ? ( - {t('mcp.environmentReads', { - names: (request.definition.environmentReferenceNames ?? []).join(', '), + {t('mcp.environmentCount', { + count: request.definition.environmentKeys.length, })} - ) : null} - {t('mcp.headerCount', { - count: request.definition.headerNames.length, - })} - {request.definition.headerNames.length > 0 ? ( - {t('mcp.headerNames', { - names: request.definition.headerNames.join(', '), + {request.definition.environmentKeys.length > 0 ? ( + {t('mcp.environmentNames', { + names: request.definition.environmentKeys.join(', '), + })} + ) : null} + {(request.definition.environmentReferenceNames?.length ?? 0) > 0 ? ( + {t('mcp.environmentReads', { + names: (request.definition.environmentReferenceNames ?? []).join(', '), + })} + ) : null} + {t('mcp.headerCount', { + count: request.definition.headerNames.length, })} - ) : null} -
-
- {t('mcpApprovals.warning')} -
+ {request.definition.headerNames.length > 0 ? ( + {t('mcp.headerNames', { + names: request.definition.headerNames.join(', '), + })} + ) : null} +
+
+ {t('mcpApprovals.warning')} +
+