diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt index d8f4ab7b..daee1c97 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheetTest.kt @@ -1,5 +1,8 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled @@ -11,6 +14,9 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.CatalogScanStatus +import com.m57.hermescontrol.data.model.ConnectionCatalogInfo +import com.m57.hermescontrol.data.model.ConnectionCatalogScan import com.m57.hermescontrol.data.model.ConnectionEnvField import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectionOperationTarget @@ -212,6 +218,115 @@ class ConnectionSetupSheetTest { composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() } + @Test + fun pluginTarget_showsSourcePinScanAndRequirements_andApprovesWithoutSecrets() { + var response: Triple, Boolean>? = null + composeTestRule.setContent { + ConnectionSetupContent( + state = state(pluginTarget()), + onRespond = { name, values, approved -> response = Triple(name, values, approved) }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Install Linear").assertIsDisplayed() + composeTestRule + .onNodeWithTag( + "connection_catalog_source", + ).assertTextContains("NousResearch/hermes-plugins/linear", substring = true) + composeTestRule.onNodeWithTag("connection_catalog_pin").assertTextContains("0123456789ab", substring = true) + composeTestRule + .onNodeWithTag("connection_catalog_scan") + .assertTextContains("Security scan found warnings · 1 advisory") + composeTestRule + .onNodeWithTag( + "connection_catalog_requirements", + ).assertTextContains("Hermes >=0.21", substring = true) + composeTestRule.onNodeWithTag("connection_catalog_profile").assertTextContains("default", substring = true) + composeTestRule.onNodeWithText("This setup step is not supported by this app version.").assertDoesNotExist() + + composeTestRule.onNodeWithTag("connection_setup_connect").assertTextContains("Install").performClick() + + assertEquals("linear", response?.first) + assertEquals(mapOf("LINEAR_API_KEY" to ""), response?.second) + assertEquals(true, response?.third) + } + + @Test + fun pluginTarget_installingAndFailedStates() { + var target = pluginTarget(state = ConnectionTargetState.INITIATED) + var current by mutableStateOf(state(target)) + composeTestRule.setContent { + ConnectionSetupContent( + state = current, + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithTag("connection_catalog_installing").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_connect").assertDoesNotExist() + + target = pluginTarget(state = ConnectionTargetState.FAILED, detail = "blocked by the kill list") + current = state(target) + composeTestRule.onNodeWithText("blocked by the kill list").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_connect").assertTextContains("Try again") + } + + @Test + fun installedSkill_reportsTheLoadableSkill() { + composeTestRule.setContent { + ConnectionSetupContent( + state = + state( + pluginTarget(state = ConnectionTargetState.CONNECTED).copy( + kind = ConnectionTargetKind.SKILL, + catalog = pluginCatalog().copy(display = "Review", skill = "review", scan = null), + ), + ), + onRespond = { _, _, _ -> }, + onContinue = {}, + onOpenBrowser = { _, _ -> }, + ) + } + + composeTestRule.onNodeWithText("Skill available: review").assertIsDisplayed() + composeTestRule.onNodeWithText("No security scan reported").assertIsDisplayed() + composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist() + } + + private fun pluginCatalog(): ConnectionCatalogInfo = + ConnectionCatalogInfo( + display = "Linear", + description = "Linear issues.", + tier = "official", + platforms = emptyList(), + repo = "NousResearch/hermes-plugins", + sha = "0123456789abcdef0123456789abcdef01234567", + subdir = "linear", + scan = ConnectionCatalogScan(CatalogScanStatus.WARNINGS, "1 advisory"), + requirements = listOf("Hermes >=0.21"), + targetProfile = "default", + skill = null, + ) + + private fun pluginTarget( + state: ConnectionTargetState = ConnectionTargetState.PENDING, + detail: String? = null, + ): ConnectionOperationTarget = + target( + name = "linear", + state = state, + requiredEnv = listOf(env("LINEAR_API_KEY", required = false, secret = true)), + ).copy( + kind = ConnectionTargetKind.PLUGIN, + action = ConnectionTargetAction.INSTALL, + detail = detail, + catalog = pluginCatalog(), + ) + private fun state(target: ConnectionOperationTarget): ConnectionOperationUiState = ConnectionOperationUiState(operation = operation(target)) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt index 064ca0b9..31a9038b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ConnectionOperationModels.kt @@ -29,6 +29,8 @@ data class ConnectionOperationTarget( val requiredEnv: List, val tools: List, val hint: String?, + /** Catalog facts for [ConnectionTargetKind.PLUGIN] / [ConnectionTargetKind.SKILL] rows only. */ + val catalog: ConnectionCatalogInfo? = null, ) { /** Safe validated browser URL. OAuth query parameters stay redacted from [toString]. */ val safeConnectUrl: String? @@ -40,9 +42,32 @@ data class ConnectionOperationTarget( "instructions=$instructions, discoveryError=$discoveryError, " + "connectUrl=${if (connectUrl == null) "null" else "[REDACTED]"}, " + "connectionId=$connectionId, attempt=$attempt, requiredEnv=$requiredEnv, " + - "tools=$tools, hint=$hint)" + "tools=$tools, hint=$hint, catalog=$catalog)" } +/** Host-resolved catalog row facts (hermes-agent `manage_catalog`); the model supplies only the id. */ +data class ConnectionCatalogInfo( + val display: String, + val description: String?, + val tier: String?, + val platforms: List, + val repo: String?, + val sha: String?, + val subdir: String?, + val scan: ConnectionCatalogScan?, + val requirements: List, + val targetProfile: String, + /** On an installed row: the skill the model can now load. */ + val skill: String?, +) + +data class ConnectionCatalogScan( + val status: CatalogScanStatus, + val summary: String?, +) + +enum class CatalogScanStatus { PASSED, WARNINGS, FAILED, UNKNOWN } + data class ConnectionEnvField( val name: String, val required: Boolean, @@ -51,7 +76,16 @@ data class ConnectionEnvField( val prompt: String?, ) -enum class ConnectionTargetKind { CONNECTOR, MCP, UNKNOWN } +enum class ConnectionTargetKind { + CONNECTOR, + MCP, + PLUGIN, + SKILL, + UNKNOWN, + ; + + val isCatalog: Boolean get() = this == PLUGIN || this == SKILL +} enum class ConnectionTargetAction { AUTHORIZE, diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt index c7c83123..088c1e4e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ConnectionOperationParser.kt @@ -1,5 +1,8 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.CatalogScanStatus +import com.m57.hermescontrol.data.model.ConnectionCatalogInfo +import com.m57.hermescontrol.data.model.ConnectionCatalogScan import com.m57.hermescontrol.data.model.ConnectionEnvField import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot import com.m57.hermescontrol.data.model.ConnectionOperationTarget @@ -58,14 +61,15 @@ object ConnectionOperationParser { val map = raw ?: return null val name = (map["name"] as? String)?.trim() ?: return null if (name.isBlank()) return null + val kind = + enumValue( + map["kind"] as? String, + ConnectionTargetKind.values(), + ConnectionTargetKind.UNKNOWN, + ) return ConnectionOperationTarget( name = name, - kind = - enumValue( - map["kind"] as? String, - ConnectionTargetKind.values(), - ConnectionTargetKind.UNKNOWN, - ), + kind = kind, action = enumValue( map["action"] as? String, @@ -90,9 +94,50 @@ object ConnectionOperationParser { ?: emptyList(), tools = (map["tools"] as? List<*>)?.filterIsInstance() ?: emptyList(), hint = map["hint"] as? String, + catalog = if (kind.isCatalog) parseCatalog(map, name) else null, ) } + private fun parseCatalog( + map: Map<*, *>, + name: String, + ): ConnectionCatalogInfo = + ConnectionCatalogInfo( + display = map.text("display") ?: name, + description = map.text("description"), + tier = map.text("tier"), + platforms = map.strings("platforms"), + repo = map.text("repo"), + sha = map.text("sha"), + subdir = map.text("subdir"), + scan = + (map["scan"] as? Map<*, *>)?.let { scan -> + ConnectionCatalogScan( + status = + enumValue( + scan["status"] as? String, + CatalogScanStatus.values(), + CatalogScanStatus.UNKNOWN, + ), + summary = scan.text("summary"), + ) + }, + requirements = map.strings("requirements"), + targetProfile = map.text("target_profile") ?: DEFAULT_PROFILE, + skill = map.text("skill"), + ) + + private fun Map<*, *>.text(key: String): String? = (this[key] as? String)?.trim()?.takeIf { it.isNotEmpty() } + + private fun Map<*, *>.strings(key: String): List = + (this[key] as? List<*>) + ?.filterIsInstance() + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?: emptyList() + + private const val DEFAULT_PROFILE = "default" + private fun parseEnv(raw: Map<*, *>?): ConnectionEnvField? { val map = raw ?: return null val name = (map["name"] as? String)?.trim() ?: return null diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt new file mode 100644 index 00000000..64a7a92c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionCatalogDetails.kt @@ -0,0 +1,122 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.CatalogScanStatus +import com.m57.hermescontrol.data.model.ConnectionCatalogInfo +import com.m57.hermescontrol.data.model.ConnectionTargetKind +import com.m57.hermescontrol.theme.LocalHermesStatusColors + +private const val SHORT_SHA_LENGTH = 12 + +/** What a plugin/skill install row would bring in: source, pin, scan and requirements (#1287). */ +@Composable +internal fun ConnectionCatalogDetails( + kind: ConnectionTargetKind, + catalog: ConnectionCatalogInfo, + modifier: Modifier = Modifier, +) { + val muted = MaterialTheme.colorScheme.onSurfaceVariant + Column( + modifier = modifier.testTag("connection_catalog_details"), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + val kindLabel = + stringResource( + if (kind == ConnectionTargetKind.SKILL) { + R.string.connection_catalog_kind_skill + } else { + R.string.connection_catalog_kind_plugin + }, + ) + val tierLabel = + when (catalog.tier) { + null -> null + "official" -> stringResource(R.string.connection_catalog_tier_official) + "community" -> stringResource(R.string.connection_catalog_tier_community) + else -> catalog.tier + } + Text( + text = listOfNotNull(kindLabel, tierLabel).joinToString(" · "), + style = MaterialTheme.typography.labelLarge, + color = muted, + ) + catalog.description?.let { Text(it) } + catalog.repo?.let { repo -> + val source = catalog.subdir?.let { "$repo/$it" } ?: repo + Text( + text = stringResource(R.string.connection_catalog_source, source), + color = muted, + modifier = Modifier.testTag("connection_catalog_source"), + ) + } + catalog.sha?.let { sha -> + Text( + text = stringResource(R.string.connection_catalog_pin, sha.take(SHORT_SHA_LENGTH)), + color = muted, + modifier = Modifier.testTag("connection_catalog_pin"), + ) + } + if (catalog.platforms.isNotEmpty()) { + Text( + text = stringResource(R.string.connection_catalog_platforms, catalog.platforms.joinToString(", ")), + color = muted, + ) + } + CatalogScanLine(catalog) + if (catalog.requirements.isNotEmpty()) { + Text( + text = + stringResource( + R.string.connection_catalog_requirements, + catalog.requirements.joinToString(", "), + ), + color = muted, + modifier = Modifier.testTag("connection_catalog_requirements"), + ) + } + Text( + text = stringResource(R.string.connection_catalog_target_profile, catalog.targetProfile), + color = muted, + modifier = Modifier.testTag("connection_catalog_profile"), + ) + } +} + +@Composable +private fun CatalogScanLine(catalog: ConnectionCatalogInfo) { + val status = LocalHermesStatusColors.current + val scan = catalog.scan + val (label, color) = + when (scan?.status) { + CatalogScanStatus.PASSED -> { + R.string.connection_catalog_scan_passed to status.success + } + + CatalogScanStatus.WARNINGS -> { + R.string.connection_catalog_scan_warnings to status.warning + } + + CatalogScanStatus.FAILED -> { + R.string.connection_catalog_scan_failed to status.error + } + + CatalogScanStatus.UNKNOWN, null -> { + R.string.connection_catalog_scan_unreported to MaterialTheme.colorScheme.onSurfaceVariant + } + } + val summary = scan?.summary + Text( + text = stringResource(label) + if (summary != null) " · $summary" else "", + color = color, + modifier = Modifier.testTag("connection_catalog_scan"), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt index 19c45c50..31ed7619 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ConnectionSetupSheet.kt @@ -102,8 +102,14 @@ internal fun ConnectionSetupContent( .padding(horizontal = 20.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + val catalog = target.catalog Text( - text = stringResource(R.string.connection_setup_target_title, target.name), + text = + if (catalog != null) { + stringResource(R.string.connection_catalog_title, catalog.display) + } else { + stringResource(R.string.connection_setup_target_title, target.name) + }, style = MaterialTheme.typography.titleLarge, modifier = Modifier.testTag("connection_setup_title"), ) @@ -120,6 +126,7 @@ internal fun ConnectionSetupContent( target.instructions?.takeIf { it.isNotBlank() }?.let { instructions -> Text(instructions, color = MaterialTheme.colorScheme.onSurfaceVariant) } + catalog?.let { ConnectionCatalogDetails(kind = target.kind, catalog = it) } when { target.state == ConnectionTargetState.CONNECTED && !target.discoveryError.isNullOrBlank() -> { @@ -141,6 +148,12 @@ internal fun ConnectionSetupContent( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + catalog?.skill?.let { skill -> + Text( + text = stringResource(R.string.connection_catalog_skill_ready, skill), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } target.detail?.takeIf { it.isNotBlank() }?.let { detail -> Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -167,6 +180,21 @@ internal fun ConnectionSetupContent( } } + catalog != null && target.state == ConnectionTargetState.INITIATED -> { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.testTag("connection_catalog_installing"), + ) { + CircularProgressIndicator() + Text( + text = + target.detail?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.connection_catalog_installing), + ) + } + } + target.state == ConnectionTargetState.UNKNOWN || (target.state == ConnectionTargetState.INITIATED && target.connectUrl != null) -> { Text( @@ -220,7 +248,15 @@ internal fun ConnectionSetupContent( .fillMaxWidth() .testTag("connection_setup_connect"), ) { - Text(stringResource(R.string.connection_setup_connect)) + Text( + stringResource( + when { + catalog == null -> R.string.connection_setup_connect + target.state == ConnectionTargetState.FAILED -> R.string.connection_catalog_retry + else -> R.string.connection_catalog_install + }, + ), + ) } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 858a5b53..15e5b90e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1557,6 +1557,24 @@ %1$s مطلوب تعذر إرسال رد الإعداد. حاول مرة أخرى. بانتظار Hermes… + تثبيت %1$s + إضافة + مهارة + رسمي + مجتمعي + المصدر: %1$s + الإيداع المثبّت: %1$s + المنصات: %1$s + المتطلبات: %1$s + يُثبَّت في الملف الشخصي: %1$s + اجتاز الفحص الأمني + وجد الفحص الأمني تحذيرات + فشل الفحص الأمني + لم يُبلَّغ عن فحص أمني + تثبيت + حاول مجددًا + جارٍ التثبيت… + المهارة متاحة: %1$s أثناء عمل الوكيل اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d85ee3c7..c659080f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1213,6 +1213,24 @@ %1$s은(는) 필수입니다 설정 응답을 보낼 수 없습니다. 다시 시도하세요. Hermes를 기다리는 중… + %1$s 설치 + 플러그인 + 스킬 + 공식 + 커뮤니티 + 소스: %1$s + 고정 커밋: %1$s + 플랫폼: %1$s + 요구 사항: %1$s + 설치 대상 프로필: %1$s + 보안 검사 통과 + 보안 검사에서 경고 발견 + 보안 검사 실패 + 보고된 보안 검사 없음 + 설치 + 다시 시도 + 설치 중… + 사용 가능한 스킬: %1$s 작업 지정 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 43541c9f..eac2130b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1509,6 +1509,24 @@ %1$s为必填项 无法发送设置响应,请重试。 正在等待 Hermes… + 安装 %1$s + 插件 + 技能 + 官方 + 社区 + 来源:%1$s + 固定提交:%1$s + 平台:%1$s + 要求:%1$s + 安装到配置文件:%1$s + 安全扫描已通过 + 安全扫描发现警告 + 安全扫描失败 + 未报告安全扫描 + 安装 + 重试 + 正在安装… + 可用技能:%1$s 智能体工作时 选择新消息如何送达当前聊天。空闲时正常发送。 修正当前轮次 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fcfe1eb..52a2de07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1742,6 +1742,24 @@ %1$s is required Couldn’t send the setup response. Try again. Waiting for Hermes… + Install %1$s + Plugin + Skill + Official + Community + Source: %1$s + Pinned commit: %1$s + Platforms: %1$s + Requires: %1$s + Installs into profile: %1$s + Security scan passed + Security scan found warnings + Security scan failed + No security scan reported + Install + Try again + Installing… + Skill available: %1$s Specify task diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt index 132ac68a..f5f4172f 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ConnectionOperationParserTest.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.ws +import com.m57.hermescontrol.data.model.CatalogScanStatus import com.m57.hermescontrol.data.model.ConnectionTargetAction import com.m57.hermescontrol.data.model.ConnectionTargetKind import com.m57.hermescontrol.data.model.ConnectionTargetState @@ -60,6 +61,73 @@ class ConnectionOperationParserTest { assertEquals(ConnectionTargetState.UNKNOWN, target.state) } + @Test + fun pluginAndSkillTargets_parseKindAndCatalogFacts() { + val payload = + completePayload() + + ( + "targets" to + listOf( + mapOf( + "name" to "linear", + "kind" to "plugin", + "action" to "install", + "state" to "pending", + "display" to " Linear ", + "description" to "Linear issues.", + "tier" to "official", + "repo" to "NousResearch/hermes-plugins", + "sha" to "0123456789abcdef0123456789abcdef01234567", + "subdir" to "linear", + "scan" to mapOf("status" to "warnings", "summary" to "1 advisory"), + "requirements" to listOf("Hermes >=0.21", "LINEAR_API_KEY environment variable"), + "platforms" to listOf("linux", ""), + "target_profile" to "work", + "required_env" to + listOf(mapOf("name" to "LINEAR_API_KEY", "required" to false, "secret" to true)), + ), + mapOf( + "name" to "owner/skills/review", + "kind" to "skill", + "action" to "install", + "state" to "connected", + "skill" to "review", + "scan" to mapOf("status" to "future-status"), + ), + ) + ) + + val (plugin, skill) = ConnectionOperationParser.parse(payload, "runtime-session")!!.targets + + assertEquals(ConnectionTargetKind.PLUGIN, plugin.kind) + val catalog = plugin.catalog!! + assertEquals("Linear", catalog.display) + assertEquals("official", catalog.tier) + assertEquals("NousResearch/hermes-plugins", catalog.repo) + assertEquals("0123456789abcdef0123456789abcdef01234567", catalog.sha) + assertEquals("linear", catalog.subdir) + assertEquals(CatalogScanStatus.WARNINGS, catalog.scan?.status) + assertEquals("1 advisory", catalog.scan?.summary) + assertEquals(2, catalog.requirements.size) + assertEquals(listOf("linux"), catalog.platforms) + assertEquals("work", catalog.targetProfile) + assertEquals(listOf("LINEAR_API_KEY"), plugin.requiredEnv.map { it.name }) + + assertEquals(ConnectionTargetKind.SKILL, skill.kind) + assertEquals("owner/skills/review", skill.catalog?.display) + assertEquals("default", skill.catalog?.targetProfile) + assertEquals("review", skill.catalog?.skill) + assertEquals(CatalogScanStatus.UNKNOWN, skill.catalog?.scan?.status) + assertNull(skill.catalog?.repo) + } + + @Test + fun nonCatalogTargets_carryNoCatalogFacts() { + val targets = ConnectionOperationParser.parse(completePayload(), "runtime-session")!!.targets + + assertTrue(targets.all { it.catalog == null }) + } + @Test fun malformedIdentityOrDeadline_isRejected() { assertNull(ConnectionOperationParser.parse(completePayload() - "op_id", "runtime-session"))