diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index a037eb5d..39551e2d 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -311,3 +311,46 @@ owns; reopen the terminal before judging the final live state. platform-independent test that exercises both certificate and keychain paths. - A changelog search conflated the stable and prerelease lines. Verify published package code before assuming a release contains the upstream patch. + +## 2026-09-06 — Model Pad responsive Settings + +- A square bounded only by window width still overflows short Settings windows; + measure the actual scrollport and the title, toolbar, axes, and legend height. + Compensate for scrollTop so browsing supporting panels cannot grow the Pad. +- Constraining the legend to a small square causes extra wrapping and consumes + the saved height. Keep the legend at column width and center the square and + axis labels independently. Remove duplicate Pad titles once Settings supplies + its shared page heading. The Electron matrix covers minimum window size and + native 125% zoom as well as wide/short layouts. + +## 2026-09-06 — Global skills gate verification + +- Skill availability has two production readers: the workspace registry and Bot + capability inventory. Gate both before discovery and check again after an + asynchronous scan; otherwise a cached or in-flight snapshot can expose skills + after disabling them. Existing skill tools also need an execution-time check. +- Android verification needs the installed Homebrew JDK 21 path; macOS + `java_home` did not discover it. iOS XCTest compiled with Xcode beta but the + default physical iPhone was locked, so verification retried the other connected + physical iPhone in accordance with the no-simulator project requirement. Both + devices were locked, so signed XCTest execution remains pending an unlocked + device; the app and test bundle compiled successfully. +- Disabling discovery alone does not remove skill instructions already expanded + into a Pi journal. The disabled execution view must project visible messages + and new turn entries for inference, compaction, and recall. Pi v4 stores an + explicit retained tail, but compaction still fences against the durable leaf: + keep that leaf identity as an inert boundary and prove JSONL reopen parity. +- Telegram commands previously expanded instructions before queue dispatch and + persisted that expansion as user text. Queue opaque skill provenance instead, + validate at dispatch and generation, and keep only raw arguments in ChatStore. + Refresh command registration after a gate change without blocking Settings; + bound that registration request so later updates cannot wait forever. + +- 2026-09-06 Settings CI: full Electron coverage exposed duplicate destination/group headings on About (also audited Memory/Remote Access), and a sidebar fixture selector that assumed the old visible path suffix. Give groups distinct labels, assert one exact destination heading across all pages, and select the new path-free workspace accessible label. +- The hosted Model Pad reachability check failed after a one-time `scrollIntoViewIfNeeded`, while the same matrix passed locally. Retry standard centered scrolling while responsive layout settles, sample the next frame, and assert against the intersection of the actual scrollport and viewport. Include matrix and geometry values in failures. A responsive-layout race is the working explanation; deterministic product overflow was not reproduced. + +- 2026-09-06 Global Skills follow-up: gate coverage must include operator compaction and every Bot catalog/edit surface, not only turn admission. Empty paused inventory must never be reconciled as removed resources; doing so churns durable incarnations on re-enable. Keep paused saved skill IDs as unavailable presentation choices with explicit global-state metadata and enforce exact preservation/subsets on the Mac. Kotlin's ordinary Boolean JSON serializer accepted quoted `"false"`; the new gate uses a strict boolean serializer and cross-client malformed-field tests. +- iOS follow-up acceptance: updated app/test bundle compiles for generic physical hardware. A fresh iPhone13 XCTest attempt reached device preflight but required unlocking; stopped the waiting test runner. Physical execution remains unverified. +- Local verification sequencing: `npm test` and `npm run build` both build native helpers at startup. Concurrent execution raced `lipo` over `build/native/aiden-worktree-remover`; keep full tests and production builds sequential in one checkout. This was a local build-output race, not a source failure. + +- 2026-09-06 Mobile catalog seam: service and model tests passed while the HTTP router rejected Android’s optional Bot query and iOS emitted only generic catalog requests. Add transport-level request tests and forward target identity end to end; per-Bot catalogs must not occupy a generic cache slot. Android has no persistent catalog cache. diff --git a/AGENTS.md b/AGENTS.md index 4c7e5d05..bdc6a5ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ Keep status colors in soft semantic fills, labels, and icons. Do not add decorat Text-entry controls must not add an accent border, outline, or ring when focused. Keep their resting border unchanged and communicate focus with the existing input-background and caret states. This rule applies to inputs, textareas, and search-field wrappers, not to non-text keyboard controls that still require a visible `focus-visible` treatment. +Settings must follow [`docs/settings-design-system.md`](docs/settings-design-system.md): use the shared page headings, grouped card surfaces, inset separators, and trailing controls derived from Appearance. Use the SD-card `MemoryCardIcon` for Memory. Never introduce brain icons or brain illustrations anywhere in the app. + ## Release model metadata models.dev may be contacted only by `npm run models:refresh`, the release refresh invoked by `npm run dist`, the scoped post-merge catalog workflow, or the user-initiated foreground **Update model catalogs** action in Settings → Providers. The live action may request only the fixed `https://models.dev/api.json` endpoint without credentials, cookies, prompts, chats, selections, custom endpoints, or a device identifier; its validated device-local cache is display-only and must never change runtime limits, routing, or selectable inventory. Never add a models.dev call to startup, normal development, unpacked builds, ordinary live-app reads, onboarding navigation, or background polling. Artificial Analysis data and credentials must never be bundled: the live Electron app may contact its fixed Free endpoint only after the user explicitly chooses Connect & fetch or Fetch latest with their own key, then reads the normalized device-local cache offline. diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt index b1b440df..cf688c93 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBot.kt @@ -13,6 +13,7 @@ import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull import sbtbiswas.AidenOnTheGo.protocol.AidenBotContractException import sbtbiswas.AidenOnTheGo.protocol.AidenRemoteProtocol import sbtbiswas.AidenOnTheGo.protocol.InstantIso8601Serializer @@ -469,6 +470,22 @@ data class AidenBotProviderOption( } } +object AidenBotSkillsEnabledSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("AidenBotSkillsEnabled", PrimitiveKind.BOOLEAN) + + override fun deserialize(decoder: Decoder): Boolean { + if (decoder !is JsonDecoder) return decoder.decodeBoolean() + val value = decoder.decodeJsonElement() as? JsonPrimitive + if (value == null || value.isString) { + throw AidenBotContractException.InvalidField("skillsEnabled") + } + return value.booleanOrNull ?: throw AidenBotContractException.InvalidField("skillsEnabled") + } + + override fun serialize(encoder: Encoder, value: Boolean) = encoder.encodeBoolean(value) +} + @Serializable data class AidenBotCapabilityCatalog( val revision: String, @@ -478,7 +495,9 @@ data class AidenBotCapabilityCatalog( val connections: List, val skills: List, val otherCapabilities: List, - val notice: AidenBotNoticeStatus + val notice: AidenBotNoticeStatus, + @Serializable(with = AidenBotSkillsEnabledSerializer::class) + val skillsEnabled: Boolean = true ) { init { AidenBotWire.validateString(revision, "revision", AidenRemoteProtocol.MAX_IDENTIFIER_LENGTH) @@ -515,7 +534,8 @@ data class AidenBotCapabilityCatalog( if (selection.shellEnabled && !shellAvailable) return false val availableFileScopes = fileScopes.filter { it.available }.map { it.id }.toSet() val availableConnections = connections.filter { it.available }.map { it.id }.toSet() - val availableSkills = skills.filter { it.available }.map { it.id }.toSet() + // Disabled catalogs expose only authenticated saved skill choices; new choices stay disabled. + val availableSkills = skills.filter { it.available || !skillsEnabled }.map { it.id }.toSet() val availableOtherCaps = otherCapabilities.filter { it.available }.map { it.id }.toSet() return availableFileScopes.containsAll(selection.fileScopeIds) && availableConnections.containsAll(selection.connectionIds) && diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt index 8f80150b..2496e584 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/networking/AidenRemoteClient.kt @@ -1031,6 +1031,7 @@ class AidenRemoteClient( } suspend fun botCapabilityCatalog(botId: String? = null): AidenBotCapabilityCatalog { + botId?.let { AidenBotWire.validateIdentifier(it, "botId", AidenRemoteProtocol.MAX_BOT_IDENTIFIER_LENGTH) } val query = if (botId != null) "?botId=$botId" else "" return executeRequest( "/bot-capabilities$query", diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotContractTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotContractTest.kt index 1f5769a0..bf7cabc7 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotContractTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotContractTest.kt @@ -1,9 +1,14 @@ package sbtbiswas.AidenOnTheGo import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject import org.junit.Assert.* import org.junit.Test import sbtbiswas.AidenOnTheGo.features.bots.aidenBotAvatarPresentation +import sbtbiswas.AidenOnTheGo.features.bots.AidenBotCustomAccessDraft import sbtbiswas.AidenOnTheGo.models.* import sbtbiswas.AidenOnTheGo.protocol.AidenBotContractException import sbtbiswas.AidenOnTheGo.protocol.AidenBotPrivateResponseScope @@ -214,6 +219,50 @@ class AidenBotContractTest { } } + @Test + fun testGloballyDisabledSkillsRejectStaleSelectionsAndAllowSkillFreeChoices() { + val fixture = loadSharedContractFixture() + val catalog = fixture.botCapabilityCatalog.copy(skills = emptyList()) + val stale = requireNotNull(fixture.botPolicyUpdate.request.custom) + assertTrue(stale.skillIds.isNotEmpty()) + assertFalse(catalog.containsAvailable(stale)) + assertTrue(catalog.containsAvailable(stale.copy(skillIds = emptyList()))) + } + + @Test + fun testDisabledSkillsPreserveSavedDraftsWithoutGrantingNewChoices() { + val fixture = loadSharedContractFixture() + val saved = requireNotNull(fixture.botPolicyUpdate.request.custom) + val catalog = fixture.botCapabilityCatalog.copy( + skillsEnabled = false, + skills = fixture.botCapabilityCatalog.skills.map { it.copy(available = false) } + ) + assertTrue(catalog.containsAvailable(saved)) + val draft = requireNotNull(AidenBotCustomAccessDraft.fromAccess(fixture.botPolicyUpdate.response, catalog)) + assertEquals(saved.skillIds.toSet(), draft.skillIDs) + assertTrue(draft.isSaveable(catalog)) + assertTrue(requireNotNull(AidenBotCustomAccessDraft.fromCatalog(catalog)).skillIDs.isEmpty()) + assertFalse(catalog.containsAvailable(saved.copy(skillIds = listOf("skill.unknown")))) + assertFalse(catalog.copy(connections = catalog.connections.map { it.copy(available = false) }).containsAvailable(saved)) + assertFalse(catalog.copy(skillsEnabled = true).containsAvailable(saved)) + assertTrue(fixture.botCapabilityCatalog.containsAvailable(saved)) + } + + @Test + fun testSkillsGateWireDefaultsAndValidation() { + val catalog = loadSharedContractFixture().botCapabilityCatalog + val fields = json.parseToJsonElement(json.encodeToString(AidenBotCapabilityCatalog.serializer(), catalog)).jsonObject + val legacy = JsonObject(fields - "skillsEnabled") + assertTrue(json.decodeFromString(legacy.toString()).skillsEnabled) + val disabled = JsonObject(fields + ("skillsEnabled" to JsonPrimitive(false))) + assertFalse(json.decodeFromString(disabled.toString()).skillsEnabled) + for (invalid in listOf(JsonNull, JsonPrimitive("false"), JsonPrimitive(0))) { + assertThrows(Exception::class.java) { + json.decodeFromString(JsonObject(fields + ("skillsEnabled" to invalid)).toString()) + } + } + } + @Test fun testBotCustomSelectionSubsetRules() { val ceiling = AidenBotCustomSelection( diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenRemoteClientTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenRemoteClientTest.kt index 97e75b70..ce531bc0 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenRemoteClientTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenRemoteClientTest.kt @@ -325,6 +325,46 @@ class AidenRemoteClientTest { assertEquals(0, httpClient.dispatcher.runningCallsCount()) } + @Test + fun testBotCapabilityCatalogRoutesSavedChoicesToTheirBot() = runBlocking { + val fixture = javaClass.classLoader!!.getResource("contract.json")!!.readText() + val catalog = Json { ignoreUnknownKeys = true }.decodeFromString( + Json.parseToJsonElement(fixture).jsonObject.getValue("botCapabilityCatalog").toString() + ) + val disabled = catalog.copy(skillsEnabled = false, skills = catalog.skills.map { it.copy(available = false) }) + val generic = disabled.copy(skills = emptyList()) + server.enqueue(MockResponse().setBody(Json.encodeToString(generic))) + server.enqueue(MockResponse().setBody(Json.encodeToString(disabled))) + + assertTrue(client.botCapabilityCatalog().skills.isEmpty()) + val genericRequest = server.takeRequest() + assertEquals("/api/aiden/v1/bot-capabilities", genericRequest.path) + + val targeted = client.botCapabilityCatalog("bot_fixture_01") + val targetRequest = server.takeRequest() + assertEquals("GET", targetRequest.method) + assertEquals("/api/aiden/v1/bot-capabilities?botId=bot_fixture_01", targetRequest.path) + assertEquals("Bearer test_credential_123", targetRequest.getHeader("Authorization")) + assertEquals(0L, targetRequest.bodySize) + assertFalse(targeted.skillsEnabled) + assertTrue(targeted.skills.isNotEmpty()) + assertEquals(disabled.skills, targeted.skills) + assertTrue(targeted.skills.all { !it.available }) + } + + @Test + fun testBotCapabilityCatalogRejectsUnsafeTargetsBeforeSending() = runBlocking { + for (id in listOf("", "bot&botId=other", "../bot", "bot?extra=true", "a".repeat(161))) { + try { + client.botCapabilityCatalog(id) + fail("Accepted invalid Bot ID: $id") + } catch (_: sbtbiswas.AidenOnTheGo.protocol.AidenBotContractException) { + // Validation must precede network access. + } + } + assertEquals(0, server.requestCount) + } + @Test fun testBotLifecycleAndIfMatchHeaders() = runBlocking { // 1. Bot list diff --git a/android/app/src/test/resources/contract.json b/android/app/src/test/resources/contract.json index ca79561b..8a433ff3 100644 --- a/android/app/src/test/resources/contract.json +++ b/android/app/src/test/resources/contract.json @@ -598,6 +598,7 @@ } }, "botCapabilityCatalog": { + "skillsEnabled": true, "revision": "bot_catalog_revision_3", "providers": [ { diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index 668d1d59..5b43ab15 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -235,7 +235,8 @@ These routes were frozen as contract in Phase 1 and implemented in Phase 4 throu - `GET /bot-favorites` and `PATCH /bot-favorites`: at most 20 unique, non-archived Bot IDs; update is whole-list replacement under `If-Match`, so membership and order change atomically. A replacement may omit archived Bots and may still edit unrelated active favorites, but adding an archived Bot returns `bot_archived`. No successful favorites response retains an archived Bot ID. - `GET /bot-conversations?cursor=…&query=…&botId=…&limit=…`: newest-first stable pages of at most 50 items, with a 200-scalar search query and 128-scalar cursor. Search is confined to Bot name/purpose, conversation title, and the bounded previews actually projected by the Mac. Every item has `updatedAt >= createdAt`. `canRespondToApproval: true` is valid only while `activityState` is `waiting_for_approval`; a waiting row may still be non-respondable when the paired device lacks authority. - `POST /bots/{botId}/chats`: open-or-create the Bot's one persistent chat. The frozen v1 response remains `201` and the operation identifier remains `createBotChat` for compatibility with shipped clients, even when the existing chat is returned. When it already exists, the Mac returns it unchanged and ignores creation-only provider/model input. When absent, an empty body inherits the Bot provider/model; otherwise one exact providerId/modelId pair is required, plus `Idempotency-Key`. Partial pairs never fall back. The creation pair must be currently available; an unavailable selection fails closed without fallback. The Mac injects the authoritative `botId` and hidden managed-home workspace; neither identifier is accepted in the body, and every successful response includes that authoritative `botId`. Concurrent calls and retries converge on the same chat. Legacy duplicate chats remain readable for recovery, but only the deterministic canonical chat is writable or projected in the Bot inbox. -- `GET /bot-capabilities`: safe revisioned provider/model, image-input capability, file-scope, shell, connection/MCP, skill, and other-capability catalog plus the full notice status. `supportsImages` is explicit and unknown capability fails closed. The response never returns display copy that the client could mistake for authority. +- `GET /bot-capabilities?botId=…`: safe revisioned provider/model, image-input capability, file-scope, shell, connection/MCP, skill, and other-capability catalog plus the full notice status. The optional, exact `botId` selects an authenticated existing Bot so its saved unavailable choices can remain visible; omitting it returns the generic creation catalog. Unknown, duplicate, malformed, empty, and overlong query values fail closed. `supportsImages` is explicit and unknown capability fails closed. The response never returns display copy that the client could mistake for authority. +- The catalog’s additive `skillsEnabled` boolean defaults to `true` when omitted. When `false`, skill inventory discovery and incarnation reconciliation pause; target-Bot catalogs may retain that Bot’s authenticated saved skill IDs as unavailable choices. Clients may preserve or remove those saved choices during unrelated edits, but must not offer unavailable unselected skills as new choices. The Mac enforces exact saved ownership and per-chat narrowing, withholds skills from all runtime contexts, and restores unchanged selections when the global gate reopens. Invalid provided values fail decoding. - `PATCH /bots/{botId}/capabilities`: `If-Match` Full/Custom update with the exact current `catalogRevision`. Full requires `confirmedForeground: true` and may carry one exact provider/model pair; omitting both preserves the saved choice for compatibility with older clients, while supplying only one is invalid. Custom contains exactly one currently available provider/model pair plus only exact positive opaque selections from that catalog. Optional `visionModel` uses three-state semantics: omitted preserves the companion, `null` clears it, and an exact provider/model object sets it only when the catalog marks that model image-capable. A supplied pair becomes revisioned durable authority, changing either primary or companion fences active turns, and the canonical persistent chat mirrors only the primary model. An unavailable model remains visibly blocked and is never replaced by fallback. - `GET /chats/{chatId}/capabilities` and `PATCH /chats/{chatId}/capabilities`: authenticated authoritative inherit/Custom view, then an `If-Match` update carrying both the exact `catalogRevision` and `expectedBotPolicyRevision`. The server rejects policy drift or any selection outside the current Bot ceiling and returns the authoritative chat subset view. - `GET /bot-access-notice`: Mac-owned acknowledgement status for this paired device. This v1 client recognizes only `bot-full-access-v1`; an unknown future version fails closed until matching copy ships. `POST /bot-access-notice/acknowledgement` accepts only that exact version, `continue_full` or `customize_first`, `confirmedForeground: true`, and an `Idempotency-Key`. Local dismissal never acknowledges it. diff --git a/docs/plans/README.md b/docs/plans/README.md index 5f00da2d..54218fa9 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -37,6 +37,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | Plan | Status | Completion note | | ---------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Unified settings and workspace presentation](completed/settings-unification-plan.md) | Complete | Workspace path preferences, responsive Model Pad, global Skills enforcement, unified Settings, native Bot catalog routing/cache isolation, tests, and adversarial reviews delivered in PR #97; final CI tracked by the PR. | | [LLM and pi-vcc Compaction](completed/pi-vcc-compaction-plan.md) | Complete | Prepared for 0.38.1: LLM default, experimental local compiler/recall, per-run inheritance, desktop controls, native activity, and signed packaged Settings/compaction/restart checks pass. | | [Provider Model Visibility and Catalog Refresh](completed/provider-model-catalog-controls-plan.md) | Complete | Provider-wide visibility, explicit dual-source updates, bounded device-local models.dev metadata, native all-hidden behavior, and credential-isolated main refresh automation ship with two-review remediation. | | [Aiden Remote Multi-Instance Hardening](completed/aiden-remote-multi-instance-hardening-plan.md) | Complete | Authenticated pairing completion, multi-device/Mac isolation, transactional listeners, explicit packaged Tailscale CLI mode, exact route ownership, durable revocation, and physical-iPhone acceptance all pass. | diff --git a/docs/plans/completed/settings-unification-plan.md b/docs/plans/completed/settings-unification-plan.md new file mode 100644 index 00000000..2808bd4c --- /dev/null +++ b/docs/plans/completed/settings-unification-plan.md @@ -0,0 +1,52 @@ +# Unified settings and workspace presentation + +Status: Complete — implementation and review complete in PR #97; final CI tracked by the PR (2026-09-06). + +## Scope + +1. Hide workspace folder labels by default. Add persisted Appearance controls to show paths and choose beginning/end, last folders, or beginning truncation. Apply consistently to workspace navigation while retaining useful branch and no-folder identity. +2. Fit Personal Model Pad to its actual available width and height, including narrow/short windows, browser panels, and zoom. Keep all controls reachable by scrolling when the window cannot contain the entire page. +3. Add a global Skills switch with authoritative runtime enforcement for configured and discovered skills, stale invocations, and chat entry points. Preserve individual skill choices. Explain the control during onboarding. +4. Use Appearance's page hierarchy, soft grouped cards, inset row separators, spacing, and right-aligned controls across Settings. Preserve input focus behavior and visible keyboard focus. Align Telegram switches to the right and correct misleading setup copy. +5. Use an SD-card icon for Memory and remove brain glyphs elsewhere. + +## Validation and delivery + +- Extend configuration, path formatting, runtime skill enforcement, settings accessibility, and responsive layout tests; register new suites. +- Inspect both native clients for shared skill behavior and run applicable focused mobile suites. +- Run type checks, relevant tests, build, and an Electron settings size matrix. Record environmental limits and workflow friction. +- Request three independent fresh-context GPT-5.6 Sol reviewers at medium effort: adversarial runtime/security, edge cases/responsiveness, and integration/accessibility/regression. Fix validated findings and rerun affected checks. +- Create a PR in sambitcreate/aiden-agent. Have a fresh GPT-5.6 Luna reviewer at max effort watch CI; fix failures until all required checks pass. +- Update project memory and archive this plan after delivery completes. + +## Implementation and verification + +- All five scope items are implemented. Settings uses shared page, group, row, and semantic token conventions documented in `docs/settings-design-system.md`. +- Workspace paths are opt-in, persist across relaunch, support all three formats, and preserve grapheme clusters and meaningful whitespace. Duplicate workspace names retain distinct short IDs when paths are hidden. +- Model Pad uses measured available space. Electron coverage includes 390–1440px widths, short windows, 125%/150% zoom, open model/insight panels, keyboard movement, and saving. A 160px minimum canvas remains scrollable in very short windows. +- Global Skills enforcement covers discovery, tools, stale attachments, queued Telegram commands, existing chat journals/compaction, and Bot runtime grants without erasing saved choices. An Electron provider-boundary test verifies hidden skill instructions are absent from the next request after disabling Skills. +- Three fresh-context GPT-5.6 Sol medium reviews covered runtime/adversarial behavior, layout/edge cases, and integration/accessibility. Confirmed findings were fixed and regression-tested; runtime re-review reported no remaining actionable findings. +- Final local checks passed: full `npm test`, production build, lint, renderer and E2E type checks, focused runtime/settings/onboarding suites, existing model-picker E2E, new settings E2E, and responsive Model Pad E2E. Android's 12 focused Bot contract tests passed. The iOS test bundle compiled, but execution was blocked because both connected physical iPhones were locked; repository policy prohibits simulator fallback. +- PR [#97](https://github.com/sambitcreate/aiden-agent/pull/97) is open. Initial CI passed main verification, native/iOS compilation, Android, and release contracts. Full Electron CI exposed duplicate group headings and an outdated workspace selector, now fixed with stronger heading coverage. Model Pad reachability now retries scrolling through layout settlement and checks the actual scrollport with diagnostic bounds. All affected local Electron checks passed; fresh CI is pending. + +## Additional PR review remediation + +- The corrected UI revision `6278ff4de` passed all CI checks, confirmed by the GPT-5.6 Luna max watcher. +- Repository automated review then identified operator-compaction and additional Bot-catalog paths. Manual LLM/VCC compaction now uses visible-only history while Skills is disabled, and disabling cancels active operator compactions. Real journal/provider tests verify no hidden skill payload or cancelled checkpoint. +- Bot catalogs pause discovery and incarnation reconciliation. Saved exact skill bindings survive disabled reads, unrelated edits, restart, and re-enable; real content changes still fail closed. Targeted editor catalogs and authenticated dormant chat-selection entries preserve saved choices without granting new skills. +- The additive optional catalog `skillsEnabled` field is documented in the normative API/OpenAPI and shared fixtures. Desktop, iOS, and Android preserve saved choices while still preventing unavailable additions and rejecting malformed flags. Android 14 focused tests passed, iOS generic app/test compilation passed, and an independent source review found no actionable concerns. Physical iPhone13 execution remains blocked by the lock screen. +- Focused remediation suites, full local tests/build/E2E, lint, type checks, and implementation CI passed after all fixes; see Delivery below. + +## Delivery + +PR [#97](https://github.com/sambitcreate/aiden-agent/pull/97) contains the completed implementation. Full local tests, production build, lint, type checks, and Electron E2E passed after all fixes. The CI workflow and Release consumer contract passed for implementation revision `4ad6e5ec1`, confirmed by the requested GPT-5.6 Luna max watcher. The final archive-only revision is tracked by the PR’s live checks. iOS physical execution remains the explicitly recorded device-lock limitation. + +## Mobile transport follow-up + +Automated review found that the HTTP catalog route rejected Android’s Bot query and iOS never supplied it. Reopened delivery and wired the optional validated query through the authenticated router and both native clients, isolate per-Bot cached catalogs on iOS, and added real HTTP/URL request regression tests. Generic new-Bot catalogs must remain free of dormant saved skill choices. + +The follow-up now forwards validated targets through the router and all existing-Bot iOS flows, keeps new-Bot catalogs generic, and isolates iOS offline catalogs by Bot. Android 33 focused model/client tests, iOS 13 source-contract tests, app/XCTest compilation, lint, types, release-consumer checks, and production build passed. Independent iOS source/test review found no issues. Full Remote API tests passed (357 plus 7 LAN transport tests); final CI is recorded in the PR checks. + +## Artwork scope update + +At the user’s request, reverted the replacement Thinking Controls onboarding illustration to the existing base asset. This PR adds or changes no onboarding image assets. Existing onboarding references and the feature asset contract remain intact. diff --git a/docs/settings-design-system.md b/docs/settings-design-system.md new file mode 100644 index 00000000..ad2d1161 --- /dev/null +++ b/docs/settings-design-system.md @@ -0,0 +1,32 @@ +# Settings design system + +Settings adapts the Appearance page and the desktop UI references in `chatgpt-desktop-ui-inspiration.md` and `chatgpt-ui-element-specimen.html`. + +## Composition + +- `SettingsPage` owns the page heading and description. Destinations with their own header/actions use `settings-page-heading` and suppress the wrapper heading. Keep one destination heading; group titles describe a distinct group rather than repeating the page title. +- `FieldSet` supplies `settings-group`, `settings-group-title`, and `settings-group-card`. Existing custom connection lists use `settings-card` for the same surface. +- `Field` supplies a label/description group, inset separator, and `settings-field-control`. Default rows put controls at the right; vertical rows suit editors, lists, previews, and complex forms. Direct switches retain a trailing column even on narrow windows. +- Use the shared Button, Switch, Input, Select, and other control primitives. Inputs keep their resting border and use background/caret focus states. Non-text controls retain a neutral `--focus-ring` outline. + +## Tokens and adaptation + +The `.settings-responsive` container defines `--settings-card-radius`, `--settings-card-fill`, `--settings-row-inset`, and `--settings-row-gap`. They derive from Aiden's semantic theme tokens; Appearance cards use these same variables. Shared heading metrics are 26/32px, with secondary copy and 26px spacing before content. Groups use soft neutral surfaces, neutral borders, inset separators, and restrained elevation. Status appears in semantic labels, icons, and fills, never decorative colored borders. + +Rows respond to their allocated content width, not the whole window. Below 540px complex controls stack under descriptions, while switches remain on the right. Grid groups must use `minmax(0, 1fr)` / `grid-cols-1` so long provider names or endpoints cannot force horizontal overflow. Controls and text must stay reachable without horizontal page scrolling. + +Model Pad measures the actual scrollport, wrapped toolbar, labels, and legend. Its square is constrained by both remaining height and column width. On very short or highly zoomed windows, it retains a usable 160px square and the Settings page scrolls; the Pad and its labels remain reachable. Ordinary window allocations show the full canvas and legend together. Opening model or benchmark panels uses the same measurement. + +## Workspace labels + +Appearance owns `showWorkspacePaths` (default false) and `workspacePathFormat` (`middle`, `end`, or `start`). Older v1 preferences normalize to hidden paths. The sidebar and picker follow persisted/live-preview changes, and measure their own text allocation so CSS does not replace the selected truncation with end clipping. Preserve legal whitespace, emoji, and combining characters. These strings are display-only; filesystem operations always use the full original path. + +Duplicate workspace names receive a short stable ID suffix in both visible and accessible names. Worktree branches and folderless workspace identity remain available when paths are hidden. Destructive confirmation and permission-scope review still identify their exact filesystem target. + +## Icons and illustrations + +Use `MemoryCardIcon`, an SD-card silhouette, for Memory. Do not introduce brain glyphs or brain illustrations. The existing onboarding artwork is outside this Settings change. + +## Checks + +`npm run test:settings-design` covers preference defaults/migration, path formats and identities, and structural/accessibility contracts. The deterministic Electron suite includes `settings-unification.spec.ts` (path persistence and all settings at 390/600/1280px) and `model-pad-responsive.spec.ts` (window/zoom/panel states, scrolling, keyboard movement, and save). Keep layout assertions tied to rendered geometry rather than only source strings. diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift index b1b545e0..b6463a23 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift @@ -223,6 +223,7 @@ struct AidenBotCustomAccessFlowView: View { capturedContext.map(coordinator.isCurrent) == true && coordinator.connectionState == .connected && coordinator.installationStore.activeInstallation?.canWriteBots == true + && selectedBot?.id == selectedBotID && selectedBot?.health != .archived && !isLoading && !isLoadingBot @@ -629,19 +630,20 @@ struct AidenBotCustomAccessFlowView: View { if let cached = await AidenBotCache.shared.load( instanceId: context.instanceId, deviceId: context.deviceId - ), let cachedList = cached.list, let cachedCatalog = cached.catalog { + ), let cachedList = cached.list { guard coordinator.isCurrent(context), sessionIdentity == expectedSession else { return } bots = cachedList.bots.filter { $0.health != .archived } - catalog = cachedCatalog if !bots.contains(where: { $0.id == selectedBotID }) { selectedBotID = bots.first(where: { $0.id == preferredBotID })?.id ?? bots.first?.id } if let selectedBotID, + let cachedCatalog = cached.catalog(forBotID: selectedBotID), let cachedDetail = cached.details.first(where: { $0.id == selectedBotID }), let cachedDraft = AidenBotCustomAccessDraft( access: cachedDetail.access, catalog: cachedCatalog ) { + catalog = cachedCatalog selectedBot = cachedDetail draft = cachedDraft cleanDraft = cachedDraft @@ -649,26 +651,13 @@ struct AidenBotCustomAccessFlowView: View { } } let client = try coordinator.remoteClient(for: context) - async let botsRequest = client.bots() - async let catalogRequest = client.botCapabilityCatalog() - let (list, loadedCatalog) = try await (botsRequest, catalogRequest) + let list = try await client.bots() guard coordinator.isCurrent(context), sessionIdentity == expectedSession else { return } capturedContext = context bots = list.bots.filter { $0.health != .archived } - catalog = loadedCatalog if !bots.contains(where: { $0.id == selectedBotID }) { selectedBotID = bots.first(where: { $0.id == preferredBotID })?.id ?? bots.first?.id } - _ = await coordinator.withRetainedInstallationData(for: context) { - _ = try? await AidenBotCache.shared.mergeAndStore( - AidenBotCacheSegments( - catalog: loadedCatalog, - notice: loadedCatalog.notice - ), - instanceId: context.instanceId, - deviceId: context.deviceId - ) - } guard coordinator.isCurrent(context), sessionIdentity == expectedSession, !Task.isCancelled else { return } isLoading = false @@ -690,7 +679,7 @@ struct AidenBotCustomAccessFlowView: View { @MainActor private func loadSelectedBot(_ request: AidenBotCustomAccessDetailRequest) async { guard detailRequest == request, coordinator.isCurrent(request.context), - let catalog, loadingBotRequest != request else { return } + loadingBotRequest != request else { return } loadingBotRequest = request defer { if loadingBotRequest == request { @@ -700,11 +689,15 @@ struct AidenBotCustomAccessFlowView: View { botError = nil if selectedBot?.id != request.botID || draft == nil { selectedBot = nil + catalog = nil draft = nil cleanDraft = nil } do { - let detail = try await coordinator.remoteClient(for: request.context).bot(id: request.botID) + let client = try coordinator.remoteClient(for: request.context) + async let detailResponse = client.bot(id: request.botID) + async let catalogResponse = client.botCapabilityCatalog(botId: request.botID) + let (detail, catalog) = try await (detailResponse, catalogResponse) guard coordinator.isCurrent(request.context), detailRequest == request, capturedContext == request.context, selectedBotID == request.botID else { return } @@ -712,10 +705,16 @@ struct AidenBotCustomAccessFlowView: View { botError = "No available AI provider and model can be selected on your Mac." return } + self.catalog = catalog selectedBot = detail draft = loadedDraft cleanDraft = loadedDraft _ = await coordinator.withRetainedInstallationData(for: request.context) { + _ = try? await AidenBotCache.shared.mergeAndStore( + AidenBotCacheSegments(catalogsByBotID: [request.botID: catalog], notice: catalog.notice), + instanceId: request.context.instanceId, + deviceId: request.context.deviceId + ) _ = try? await AidenBotCache.shared.upsertDetailAndStore( detail, instanceId: request.context.instanceId, @@ -786,7 +785,7 @@ struct AidenBotCustomAccessFlowView: View { do { let client = try coordinator.remoteClient(for: request.context) async let detailRequest = client.bot(id: request.botID) - async let catalogRequest = client.botCapabilityCatalog() + async let catalogRequest = client.botCapabilityCatalog(botId: request.botID) let (authoritative, refreshedCatalog) = try await (detailRequest, catalogRequest) guard coordinator.isCurrent(request.context), savingRequest == request, capturedContext == request.context, diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift index faf34b25..dcd94c0c 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotEditorView.swift @@ -10,6 +10,13 @@ enum AidenBotEditorMode: Identifiable, Sendable { case create(defaultAccess: AidenBotEditorDefaultAccess) case edit(botID: String) + var catalogBotID: String? { + switch self { + case .create: nil + case let .edit(botID): botID + } + } + var id: String { switch self { case let .create(defaultAccess): "create-\(String(describing: defaultAccess))" @@ -1089,7 +1096,7 @@ struct AidenBotEditorView: View { if let cached = await AidenBotCache.shared.load( instanceId: context.instanceId, deviceId: context.deviceId - ), let cachedCatalog = cached.catalog { + ), let cachedCatalog = cached.catalog(forBotID: mode.catalogBotID) { let cachedBot: AidenBotDetail? switch mode { case .create: @@ -1118,7 +1125,7 @@ struct AidenBotEditorView: View { loadedCatalog = try await client.botCapabilityCatalog() loadedBot = nil case let .edit(botID): - async let catalogRequest = client.botCapabilityCatalog() + async let catalogRequest = client.botCapabilityCatalog(botId: botID) async let detailRequest = client.bot(id: botID) (loadedCatalog, loadedBot) = try await (catalogRequest, detailRequest) } @@ -1146,7 +1153,8 @@ struct AidenBotEditorView: View { _ = await coordinator.withRetainedInstallationData(for: context) { _ = try? await AidenBotCache.shared.mergeAndStore( AidenBotCacheSegments( - catalog: loadedCatalog, + catalog: mode.catalogBotID == nil ? loadedCatalog : nil, + catalogsByBotID: mode.catalogBotID.map { [$0: loadedCatalog] }, notice: loadedCatalog.notice ), instanceId: context.instanceId, @@ -1334,7 +1342,7 @@ struct AidenBotEditorView: View { do { let client = try coordinator.remoteClient(for: attempt.context) async let detailRequest = client.bot(id: attempt.botID) - async let catalogRequest = client.botCapabilityCatalog() + async let catalogRequest = client.botCapabilityCatalog(botId: attempt.botID) let (authoritative, refreshedCatalog) = try await (detailRequest, catalogRequest) guard isCurrent(attempt) else { return } let rebasedDraft = try aidenBotEditorRebasedDraft( diff --git a/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift b/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift index d81f7d45..7750606a 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift @@ -296,7 +296,7 @@ final class AidenBotChatToolsModel { coordinator.installationStore.activeInstallation?.id == installation.id, coordinator.installationStore.activeInstallation?.deviceId == installation.deviceId { bot = cached.details.first(where: { $0.id == botID }) - catalog = cached.catalog + catalog = cached.catalog(forBotID: botID) } do { let context = try coordinator.requestContext() @@ -305,7 +305,7 @@ final class AidenBotChatToolsModel { let client = try coordinator.remoteClient(for: context) async let botRequest = client.bot(id: botID) async let accessRequest = client.botChatAccess(chatId: chatID) - async let catalogRequest = client.botCapabilityCatalog() + async let catalogRequest = client.botCapabilityCatalog(botId: botID) let (loadedBot, loadedAccess, loadedCatalog) = try await ( botRequest, accessRequest, catalogRequest ) @@ -331,7 +331,7 @@ final class AidenBotChatToolsModel { details.removeAll { $0.id == loadedBot.id } details.append(loadedBot) _ = try? await cache.mergeAndStore( - AidenBotCacheSegments(details: details, catalog: loadedCatalog), + AidenBotCacheSegments(details: details, catalogsByBotID: [botID: loadedCatalog]), instanceId: context.instanceId, deviceId: context.deviceId ) @@ -820,7 +820,7 @@ final class AidenBotConversationFilesModel { let client = try coordinator.remoteClient(for: grant.context) async let accessRequest = client.botChatAccess(chatId: grant.chatID) async let botRequest = client.bot(id: grant.botID) - async let catalogRequest = client.botCapabilityCatalog() + async let catalogRequest = client.botCapabilityCatalog(botId: grant.botID) async let filesRequest = client.botConversationFiles(chatId: grant.chatID) let (access, bot, catalog, files) = try await ( accessRequest, botRequest, catalogRequest, filesRequest @@ -907,7 +907,7 @@ final class AidenBotConversationFilesModel { ) async throws { async let accessRequest = client.botChatAccess(chatId: grant.chatID) async let botRequest = client.bot(id: grant.botID) - async let catalogRequest = client.botCapabilityCatalog() + async let catalogRequest = client.botCapabilityCatalog(botId: grant.botID) let (access, bot, catalog) = try await (accessRequest, botRequest, catalogRequest) guard coordinator.isCurrent(grant.context), access.chatId == grant.chatID, diff --git a/ios/AidenOnTheGo/Models/AidenBot.swift b/ios/AidenOnTheGo/Models/AidenBot.swift index 1ff9cc63..10b60d24 100644 --- a/ios/AidenOnTheGo/Models/AidenBot.swift +++ b/ios/AidenOnTheGo/Models/AidenBot.swift @@ -1007,6 +1007,7 @@ struct AidenBotCapabilityCatalog: Codable, Equatable, Sendable { let shellAvailable: Bool let connections: [AidenBotCapabilityOption] let skills: [AidenBotCapabilityOption] + let skillsEnabled: Bool let otherCapabilities: [AidenBotCapabilityOption] let notice: AidenBotNoticeStatus @@ -1022,6 +1023,8 @@ struct AidenBotCapabilityCatalog: Codable, Equatable, Sendable { shellAvailable = try values.decode(Bool.self, forKey: .shellAvailable) connections = try values.decode([AidenBotCapabilityOption].self, forKey: .connections) skills = try values.decode([AidenBotCapabilityOption].self, forKey: .skills) + skillsEnabled = values.contains(.skillsEnabled) + ? try values.decode(Bool.self, forKey: .skillsEnabled) : true otherCapabilities = try values.decode([AidenBotCapabilityOption].self, forKey: .otherCapabilities) notice = try values.decode(AidenBotNoticeStatus.self, forKey: .notice) @@ -1056,6 +1059,8 @@ struct AidenBotCapabilityCatalog: Codable, Equatable, Sendable { .models.first(where: { $0.id == modelId }) } + // Disabled catalogs contain only authenticated retained skills; selection controls + // still prohibit adding an unavailable choice and the Mac enforces saved ownership. func containsAvailable(_ selection: AidenBotCustomSelection) -> Bool { guard containsAvailable(providerId: selection.providerId, modelId: selection.modelId), !selection.shellEnabled || shellAvailable else { @@ -1063,7 +1068,7 @@ struct AidenBotCapabilityCatalog: Codable, Equatable, Sendable { } return Set(selection.fileScopeIds).isSubset(of: Set(fileScopes.filter(\.available).map(\.id))) && Set(selection.connectionIds).isSubset(of: Set(connections.filter(\.available).map(\.id))) - && Set(selection.skillIds).isSubset(of: Set(skills.filter(\.available).map(\.id))) + && Set(selection.skillIds).isSubset(of: Set(skills.filter { $0.available || !skillsEnabled }.map(\.id))) && Set(selection.otherCapabilityIds).isSubset(of: Set(otherCapabilities.filter(\.available).map(\.id))) } diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift index d44a0302..d89f0fd9 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift @@ -1074,8 +1074,13 @@ final class AidenRemoteClient: @unchecked Sendable { return response.chat } - func botCapabilityCatalog() async throws -> AidenBotCapabilityCatalog { - try await send(method: "GET", path: ["bot-capabilities"]) + func botCapabilityCatalog(botId: String? = nil) async throws -> AidenBotCapabilityCatalog { + if let botId { try validateBotIdentifier(botId) } + return try await send( + method: "GET", + path: ["bot-capabilities"], + query: botId.map { [URLQueryItem(name: "botId", value: $0)] } ?? [] + ) } func updateBotAccess( diff --git a/ios/AidenOnTheGo/Persistence/AidenBotCache.swift b/ios/AidenOnTheGo/Persistence/AidenBotCache.swift index 719db89c..20464066 100644 --- a/ios/AidenOnTheGo/Persistence/AidenBotCache.swift +++ b/ios/AidenOnTheGo/Persistence/AidenBotCache.swift @@ -10,6 +10,7 @@ struct AidenBotCacheSnapshot: Codable, Equatable, Sendable { var details: [AidenBotDetail] var conversations: AidenBotConversationPage? var catalog: AidenBotCapabilityCatalog? + var catalogsByBotID: [String: AidenBotCapabilityCatalog]? var notice: AidenBotNoticeStatus? var savedAt: Date @@ -18,6 +19,7 @@ struct AidenBotCacheSnapshot: Codable, Equatable, Sendable { details: [AidenBotDetail] = [], conversations: AidenBotConversationPage? = nil, catalog: AidenBotCapabilityCatalog? = nil, + catalogsByBotID: [String: AidenBotCapabilityCatalog]? = nil, notice: AidenBotNoticeStatus? = nil, savedAt: Date = Date() ) { @@ -25,9 +27,17 @@ struct AidenBotCacheSnapshot: Codable, Equatable, Sendable { self.details = details self.conversations = conversations self.catalog = catalog + self.catalogsByBotID = catalogsByBotID self.notice = notice self.savedAt = savedAt } + + /// A generic catalog cannot represent a Bot's retained or private skills. + /// Missing scoped data stays missing rather than borrowing another catalog. + func catalog(forBotID botID: String?) -> AidenBotCapabilityCatalog? { + if let botID { return catalogsByBotID?[botID] } + return catalog + } } /// A partial cache refresh. A `nil` member means that segment was not fetched @@ -38,6 +48,7 @@ struct AidenBotCacheSegments: Sendable { var details: [AidenBotDetail]? var conversations: AidenBotConversationPage? var catalog: AidenBotCapabilityCatalog? + var catalogsByBotID: [String: AidenBotCapabilityCatalog]? var notice: AidenBotNoticeStatus? init( @@ -45,12 +56,14 @@ struct AidenBotCacheSegments: Sendable { details: [AidenBotDetail]? = nil, conversations: AidenBotConversationPage? = nil, catalog: AidenBotCapabilityCatalog? = nil, + catalogsByBotID: [String: AidenBotCapabilityCatalog]? = nil, notice: AidenBotNoticeStatus? = nil ) { self.list = list self.details = details self.conversations = conversations self.catalog = catalog + self.catalogsByBotID = catalogsByBotID self.notice = notice } @@ -72,11 +85,17 @@ struct AidenBotCacheSegments: Sendable { ) } } ?? mergedConversations + var scopedCatalogs = existing?.catalogsByBotID ?? [:] + scopedCatalogs.merge(catalogsByBotID ?? [:]) { _, fresh in fresh } + if let retainedBotIDs { + scopedCatalogs = scopedCatalogs.filter { retainedBotIDs.contains($0.key) } + } return AidenBotCacheSnapshot( list: list ?? existing?.list, details: prunedDetails, conversations: prunedConversations, catalog: catalog ?? existing?.catalog, + catalogsByBotID: scopedCatalogs.isEmpty ? nil : scopedCatalogs, notice: notice ?? existing?.notice, savedAt: savedAt ) @@ -373,8 +392,14 @@ actor AidenBotCache { snapshot.savedAt.timeIntervalSince1970.isFinite else { return false } + let scopedIDs = Array((snapshot.catalogsByBotID ?? [:]).keys) + guard scopedIDs.count <= 256, scopedIDs.allSatisfy({ + !$0.isEmpty && $0.unicodeScalars.count <= AidenRemoteProtocol.maxBotIdentifierLength + && $0.range(of: "^[A-Za-z0-9._:-]+$", options: .regularExpression) == ($0.startIndex..<$0.endIndex) + }) else { return false } if let list = snapshot.list { let listed = Set(list.bots.map(\.id)) + guard scopedIDs.allSatisfy(listed.contains) else { return false } guard details.allSatisfy({ listed.contains($0.id) }) else { return false } } if let conversations = snapshot.conversations { diff --git a/ios/AidenOnTheGoTests/AidenBotCacheTests.swift b/ios/AidenOnTheGoTests/AidenBotCacheTests.swift index 5a35e232..82a51e86 100644 --- a/ios/AidenOnTheGoTests/AidenBotCacheTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotCacheTests.swift @@ -13,6 +13,69 @@ final class AidenBotCacheTests: XCTestCase { ) } + func testTargetedCatalogsNeverOverwriteOrFallbackToGlobalOrAnotherBot() async throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "aiden-bot-catalog-scopes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let cache = AidenBotCache(root: root) + let generic = try fixture().botCapabilityCatalog + func scoped(_ id: String) throws -> AidenBotCapabilityCatalog { + var data = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(generic)) as? [String: Any]) + data["revision"] = "catalog-\(id)" + data["skillsEnabled"] = false + data["skills"] = [["id": "skill-\(id)", "label": "Saved skill", "available": false]] + return try JSONDecoder.aidenRemote().decode(AidenBotCapabilityCatalog.self, from: JSONSerialization.data(withJSONObject: data)) + } + let first = try scoped("a") + let second = try scoped("b") + let activation = await cache.activate(instanceId: "instance", deviceId: "device") + _ = try await cache.store(AidenBotCacheSnapshot(catalog: generic), activation: activation) + let legacy = await cache.load(instanceId: "instance", deviceId: "device") + XCTAssertEqual(legacy?.catalog(forBotID: nil), generic) + XCTAssertNil(legacy?.catalog(forBotID: "bot:a"), "Legacy global data is not scoped authority") + _ = try await cache.mergeAndStore(AidenBotCacheSegments(catalogsByBotID: ["bot:a": first]), activation: activation) + _ = try await cache.mergeAndStore(AidenBotCacheSegments(catalogsByBotID: ["bot:b": second]), activation: activation) + let restarted = AidenBotCache(root: root) + let restored = await restarted.load(instanceId: "instance", deviceId: "device") + XCTAssertEqual(restored?.catalog(forBotID: nil), generic) + XCTAssertEqual(restored?.catalog(forBotID: "bot:a"), first) + XCTAssertEqual(restored?.catalog(forBotID: "bot:b"), second) + XCTAssertNil(restored?.catalog(forBotID: "bot:unknown")) + let otherPairing = await restarted.load(instanceId: "instance", deviceId: "other-device") + XCTAssertNil(otherPairing) + for id in ["../bad", "bot:newline\n", "", String(repeating: "a", count: AidenRemoteProtocol.maxBotIdentifierLength + 1)] { + let invalid = try await cache.mergeAndStore(AidenBotCacheSegments(catalogsByBotID: [id: first]), activation: activation) + XCTAssertNil(invalid) + } + let afterInvalid = await cache.load(instanceId: "instance", deviceId: "device") + XCTAssertEqual(afterInvalid, restored) + let otherActivation = await cache.activate(instanceId: "other-instance", deviceId: "device") + let stale = try await cache.mergeAndStore(AidenBotCacheSegments(catalogsByBotID: ["bot:a": second]), activation: activation) + XCTAssertNil(stale) + let current = await cache.isCurrent(otherActivation) + XCTAssertTrue(current) + } + + func testLegacyCacheDecodesWithoutScopedCatalogsAndListRefreshPrunesDeletedBotScopes() throws { + let contract = try fixture() + let snapshot = AidenBotCacheSnapshot(catalog: contract.botCapabilityCatalog) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + var wire = try XCTUnwrap(JSONSerialization.jsonObject(with: encoder.encode(snapshot)) as? [String: Any]) + wire.removeValue(forKey: "catalogsByBotID") + let legacy = try JSONDecoder.aidenRemote().decode(AidenBotCacheSnapshot.self, from: JSONSerialization.data(withJSONObject: wire)) + XCTAssertNil(legacy.catalogsByBotID) + XCTAssertEqual(legacy.catalog, contract.botCapabilityCatalog) + let retainedID = try XCTUnwrap(contract.botList.bots.first?.id) + let existing = AidenBotCacheSnapshot(catalog: contract.botCapabilityCatalog, catalogsByBotID: [ + retainedID: contract.botCapabilityCatalog, + "bot:deleted": contract.botCapabilityCatalog, + ]) + let pruned = AidenBotCacheSegments(list: contract.botList).applying(to: existing, savedAt: Date()) + XCTAssertEqual(Set(pruned.catalogsByBotID?.keys ?? Dictionary().keys), [retainedID]) + XCTAssertEqual(pruned.catalog, existing.catalog) + } + func testBotCacheIsInstanceScopedAndRejectsAtoBtoAStalePublication() async throws { let root = FileManager.default.temporaryDirectory .appending(path: "aiden-bot-cache-\(UUID().uuidString)", directoryHint: .isDirectory) diff --git a/ios/AidenOnTheGoTests/AidenBotContractTests.swift b/ios/AidenOnTheGoTests/AidenBotContractTests.swift index 72ae0e2e..e2e752c3 100644 --- a/ios/AidenOnTheGoTests/AidenBotContractTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotContractTests.swift @@ -1496,6 +1496,73 @@ final class AidenBotContractTests: XCTestCase { ) } + func testGloballyDisabledSkillsRejectStaleSelectionsAndAllowSkillFreeChoices() throws { + let fixture = try sharedFixtureObject() + var catalogObject = try XCTUnwrap(fixture["botCapabilityCatalog"] as? [String: Any]) + catalogObject["skills"] = [] as [[String: Any]] + let catalog = try AidenRemoteJSONDecoder.decode( + AidenBotCapabilityCatalog.self, from: data(for: catalogObject) + ) + let update = try XCTUnwrap(fixture["botPolicyUpdate"] as? [String: Any]) + let request = try XCTUnwrap(update["request"] as? [String: Any]) + var selectionObject = try XCTUnwrap(request["custom"] as? [String: Any]) + let stale = try AidenRemoteJSONDecoder.decode( + AidenBotCustomSelection.self, from: data(for: selectionObject) + ) + XCTAssertFalse(stale.skillIds.isEmpty) + XCTAssertFalse(catalog.containsAvailable(stale)) + selectionObject["skillIds"] = [] as [String] + let skillFree = try AidenRemoteJSONDecoder.decode( + AidenBotCustomSelection.self, from: data(for: selectionObject) + ) + XCTAssertTrue(catalog.containsAvailable(skillFree)) + } + + func testDisabledSkillsPreserveSavedDraftsWithoutGrantingNewChoices() throws { + let fixture = try sharedFixtureObject() + let decoded = try AidenRemoteJSONDecoder.decode(AidenRemoteContractFixture.self, from: data(for: fixture)) + let saved = try XCTUnwrap(decoded.botPolicyUpdate.response.custom) + var object = try XCTUnwrap(fixture["botCapabilityCatalog"] as? [String: Any]) + object["skillsEnabled"] = false + object["skills"] = try XCTUnwrap(object["skills"] as? [[String: Any]]).map { value in + var option = value + option["available"] = false + return option + } + let catalog = try AidenRemoteJSONDecoder.decode(AidenBotCapabilityCatalog.self, from: data(for: object)) + XCTAssertTrue(catalog.containsAvailable(saved)) + let draft = try XCTUnwrap(AidenBotCustomAccessDraft(access: decoded.botPolicyUpdate.response, catalog: catalog)) + XCTAssertEqual(draft.skillIDs, Set(saved.skillIds)) + XCTAssertTrue(draft.isSaveable(in: catalog)) + XCTAssertTrue(try XCTUnwrap(AidenBotCustomAccessDraft(catalog: catalog)).skillIDs.isEmpty) + var unknownDraft = draft + unknownDraft.skillIDs = ["skill.unknown"] + XCTAssertFalse(unknownDraft.isSaveable(in: catalog)) + var unavailableConnection = object + unavailableConnection["connections"] = try XCTUnwrap(object["connections"] as? [[String: Any]]).map { value in + var option = value + option["available"] = false + return option + } + XCTAssertFalse(try AidenRemoteJSONDecoder.decode(AidenBotCapabilityCatalog.self, from: data(for: unavailableConnection)).containsAvailable(saved)) + object["skillsEnabled"] = true + XCTAssertFalse(try AidenRemoteJSONDecoder.decode(AidenBotCapabilityCatalog.self, from: data(for: object)).containsAvailable(saved)) + XCTAssertTrue(decoded.botCapabilityCatalog.containsAvailable(saved)) + } + + func testSkillsGateWireDefaultsAndValidation() throws { + let fixture = try sharedFixtureObject() + var object = try XCTUnwrap(fixture["botCapabilityCatalog"] as? [String: Any]) + object.removeValue(forKey: "skillsEnabled") + XCTAssertTrue(try AidenRemoteJSONDecoder.decode(AidenBotCapabilityCatalog.self, from: data(for: object)).skillsEnabled) + object["skillsEnabled"] = false + XCTAssertFalse(try AidenRemoteJSONDecoder.decode(AidenBotCapabilityCatalog.self, from: data(for: object)).skillsEnabled) + for invalid: Any in [NSNull(), "false", 0] { + object["skillsEnabled"] = invalid + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decode(AidenBotCapabilityCatalog.self, from: data(for: object))) + } + } + func testCatalogKeepsResponseTombstonesButRejectsUnavailableMutationSelections() throws { let fixture = try sharedFixtureObject() var catalogObject = try XCTUnwrap(fixture["botCapabilityCatalog"] as? [String: Any]) diff --git a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift index daf94865..512d5177 100644 --- a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift +++ b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift @@ -3707,6 +3707,47 @@ final class AidenRemoteClientTests: XCTestCase { XCTAssertNil(restored) } + func testBotCatalogRequestsUseExactTargetAndKeepLegacyCreateGeneric() async throws { + let client = makeClient() + let data = try botFixtureData(at: ["botCapabilityCatalog"]) + let targets: [String?] = [nil, "bot:first", "bot:second"] + var requests = 0 + AidenRemoteMockURLProtocol.handler = { request in + let target = targets[requests] + requests += 1 + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/aiden/v1/bot-capabilities") + let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + XCTAssertEqual(query, target.map { [URLQueryItem(name: "botId", value: $0)] } ?? []) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer device-credential") + XCTAssertFalse(request.url!.absoluteString.contains("device-credential")) + return Self.response(for: request, status: 200, data: data) + } + _ = try await client.botCapabilityCatalog() + _ = try await client.botCapabilityCatalog(botId: "bot:first") + _ = try await client.botCapabilityCatalog(botId: "bot:second") + XCTAssertEqual(requests, 3) + } + + func testBotCatalogRejectsInvalidTargetsBeforeIssuingAnyRequest() async throws { + let client = makeClient() + var requests = 0 + AidenRemoteMockURLProtocol.handler = { request in + requests += 1 + XCTFail("Invalid Bot target reached the transport: \(request.url?.path ?? "")") + throw AidenRemoteClientError.invalidResponse + } + for target in ["", "../bot", "bot/other", "bot?other", "bot&other", "bot other", "bot\nother", String(repeating: "a", count: AidenRemoteProtocol.maxBotIdentifierLength + 1)] { + do { + _ = try await client.botCapabilityCatalog(botId: target) + XCTFail("Invalid Bot target was accepted") + } catch { + XCTAssertTrue(error is AidenRemoteClientError) + } + } + XCTAssertEqual(requests, 0) + } + private func makeClient() -> AidenRemoteClient { AidenRemoteClient( endpoint: URL(string: "https://aiden.test/api/aiden/v1")!, diff --git a/main/handlers/bots.ts b/main/handlers/bots.ts index aed92ffc..ce63cace 100644 --- a/main/handlers/bots.ts +++ b/main/handlers/bots.ts @@ -189,8 +189,8 @@ export function registerBotHandlers(): void { ipcMain.handle("bots:update", async (_event, input: unknown) => { return botApplicationService.updateBot(parseBotUpdate(input)); }); - ipcMain.handle("bots:getCapabilityCatalog", async () => - botApplicationService.capabilityCatalog(desktopAudienceId), + ipcMain.handle("bots:getCapabilityCatalog", async (_event, id: unknown) => + botApplicationService.capabilityCatalog(desktopAudienceId, id === undefined ? undefined : parseBotId(id)), ); ipcMain.handle("bots:getBotAccess", async (_event, id: unknown) => { const botId = parseBotId(id); diff --git a/main/handlers/providers.ts b/main/handlers/providers.ts index f516c446..94e1e1bc 100644 --- a/main/handlers/providers.ts +++ b/main/handlers/providers.ts @@ -3,6 +3,7 @@ import { isCompactionEngine } from "../../renderer/shared/compaction.js"; import { ipcMain } from "../platform.js"; import { configStore } from "../services/config-store.js"; +import { skillRegistry } from "../services/skill-registry-main.js"; import { canUseStoredProviderKey } from "../services/provider-key-policy.js"; import { secrets } from "../services/secrets.js"; import { @@ -517,6 +518,10 @@ export function registerProviderHandlers(): void { next.compactionEngine = p.compactionEngine; } if (typeof p.memoryEnabled === "boolean") next.memoryEnabled = p.memoryEnabled; + if (p.skillsEnabled !== undefined) { + if (typeof p.skillsEnabled !== "boolean") throw new Error("Invalid skills enabled setting."); + next.skillsEnabled = p.skillsEnabled; + } if (typeof p.dictationAccelerator === "string") next.dictationAccelerator = p.dictationAccelerator; if ( @@ -528,6 +533,19 @@ export function registerProviderHandlers(): void { } if (p.appearance !== undefined) next.appearance = parseAppearanceConfig(p.appearance); const saved = await configStore.setSettings(next); + if (next.skillsEnabled !== undefined) { + skillRegistry.invalidate(); + invalidateBotRuntimeInventoryAuthority("skill_configuration"); + if (!next.skillsEnabled) { + const { llmClient } = await import("../services/llm-client.js"); + llmClient.cancelForSkillsDisabled(); + const { contextLifecycleService } = + await import("../services/context-lifecycle-service-main.js"); + contextLifecycleService.cancelForSkillsDisabled(); + } + const { telegramService } = await import("../services/telegram/telegram-service.js"); + void telegramService.refreshCommands(); + } if (next.appearance) { const appearance = appearancePreview.persisted(normalizeAppearanceConfig(saved.appearance)); ipcMain.broadcast("settings:appearance-changed", appearance); diff --git a/main/services/aiden-remote-bots.test.ts b/main/services/aiden-remote-bots.test.ts index fb44c746..496bb017 100644 --- a/main/services/aiden-remote-bots.test.ts +++ b/main/services/aiden-remote-bots.test.ts @@ -24,6 +24,7 @@ import { type AidenIdempotencySnapshot, } from "./aiden-remote-operation-contract.js"; import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { parseAidenRemoteBotCapabilityCatalog } from "./aiden-remote-protocol.js"; import type { Chat } from "./types.js"; const CATALOG_REVISION = "catalog_revision_1"; @@ -100,6 +101,10 @@ function fixture( ) => Promise; onArchiveBot?: (botId: string) => Promise; updateBotAccessError?: unknown; + capabilityCatalog?: ( + audienceId: string, + botId?: string, + ) => ReturnType | Promise>; } = {}, ) { let bots = initial.map((entry) => structuredClone(entry)); @@ -234,7 +239,9 @@ function fixture( )[0]; return selected ? structuredClone(selected) : null; }, - async capabilityCatalog() { return catalog(); }, + async capabilityCatalog(audienceId: string, botId?: string) { + return options.capabilityCatalog?.(audienceId, botId) ?? catalog(); + }, async getBotAccess(botId: string) { const policy = policies.get(botId); if (!policy) throw new Error("missing"); @@ -983,3 +990,38 @@ test("favorites storage rejects corrupt, duplicate, and oversized snapshots", () })); assert.throws(() => normalizeAidenRemoteBotFavoritesSnapshot({ version: 2, botIds: [] })); }); + + +test("Remote catalog preserves the strict optional global Skills gate", () => { + const saved = { ...catalog(), skills: [{ id: "skill:saved", label: "Saved skill", available: false }] }; + saved.providers[0]!.models[0]!.supportsImages = false; + assert.equal(parseAidenRemoteBotCapabilityCatalog(saved).skillsEnabled, undefined); + assert.equal(parseAidenRemoteBotCapabilityCatalog({ ...saved, skillsEnabled: false }).skillsEnabled, false); + assert.equal(parseAidenRemoteBotCapabilityCatalog({ ...saved, skillsEnabled: true }).skillsEnabled, true); + for (const invalid of [null, "false", 0, {}, []]) { + assert.throws(() => parseAidenRemoteBotCapabilityCatalog({ ...saved, skillsEnabled: invalid }), /skillsEnabled/u); + } +}); + +test("Remote targeted catalogs validate Bot ownership before forwarding the audience and target", async () => { + const calls: Array<[string, string | undefined]> = []; + const app = fixture([bot("bot_owned")], { + capabilityCatalog: (audienceId, botId) => { + calls.push([audienceId, botId]); + const result = catalog(); + result.providers[0]!.models[0]!.supportsImages = false; + return result; + }, + }); + + await app.service.capabilityCatalog("device_authorized", "bot_owned"); + assert.deepEqual(calls, [["device_authorized", "bot_owned"]]); + + await assert.rejects( + app.service.capabilityCatalog("device_authorized", "bot_missing"), + (error: unknown) => + (error as { code?: string; status?: number }).code === "not_found" && + (error as { status?: number }).status === 404, + ); + assert.deepEqual(calls, [["device_authorized", "bot_owned"]]); +}); diff --git a/main/services/aiden-remote-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index d0fef36f..85831665 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -372,6 +372,20 @@ test("Bot OpenAPI freezes bounded DTOs, conjunctive grants, and privacy-safe rou return record(record(content["application/json"], "application/json").schema, "request schema").$ref; }; + const botIdQuery = record(parameters.BotIdQuery, "BotIdQuery"); + assert.equal(botIdQuery.name, "botId"); + assert.equal(botIdQuery.in, "query"); + assert.equal(botIdQuery.required, false); + assert.deepEqual(record(botIdQuery.schema, "BotIdQuery schema"), { + type: "string", + minLength: 1, + maxLength: 160, + pattern: "^[A-Za-z0-9._:-]+$", + }); + assert.deepEqual(operation("/bot-capabilities", "get").parameters, [ + { $ref: "#/components/parameters/BotIdQuery" }, + ]); + assert.deepEqual( record(document["x-aiden-json-response-emission"], "JSON response emission"), { @@ -581,7 +595,7 @@ test("Bot OpenAPI freezes bounded DTOs, conjunctive grants, and privacy-safe rou assert.deepEqual(record(queryParameter("limit").schema, "limit schema"), { type: "integer", minimum: 1, maximum: 50, default: 30 }); const catalogProperties = record(record(schemas.BotCapabilityCatalog, "BotCapabilityCatalog").properties, "BotCapabilityCatalog properties"); - assert.deepEqual(Object.keys(catalogProperties), ["revision", "providers", "fileScopes", "shellAvailable", "connections", "skills", "otherCapabilities", "notice"]); + assert.deepEqual(Object.keys(catalogProperties), ["revision", "providers", "fileScopes", "shellAvailable", "connections", "skillsEnabled", "skills", "otherCapabilities", "notice"]); assert.equal(record(catalogProperties.providers, "providers").maxItems, 64); assert.equal( record(catalogProperties.providers, "providers")["x-aiden-max-total-models"], @@ -589,6 +603,8 @@ test("Bot OpenAPI freezes bounded DTOs, conjunctive grants, and privacy-safe rou ); assert.equal(record(catalogProperties.connections, "connections").maxItems, 128); assert.equal(record(catalogProperties.skills, "skills").maxItems, 256); + assert.equal(record(catalogProperties.skillsEnabled, "skillsEnabled").type, "boolean"); + assert.equal(record(catalogProperties.skillsEnabled, "skillsEnabled").default, true); assert.equal(record(catalogProperties.notice, "notice").$ref, "#/components/schemas/BotAccessNoticeStatus"); const customSelection = record(schemas.BotCustomSelection, "BotCustomSelection"); assert.deepEqual(customSelection.required, ["providerId", "modelId", "fileScopeIds", "shellEnabled", "connectionIds", "skillIds", "otherCapabilityIds"]); diff --git a/main/services/aiden-remote-protocol.ts b/main/services/aiden-remote-protocol.ts index 3966fdae..379720c8 100644 --- a/main/services/aiden-remote-protocol.ts +++ b/main/services/aiden-remote-protocol.ts @@ -343,6 +343,8 @@ export interface AidenRemoteBotCapabilityCatalog { shellAvailable: boolean; connections: AidenRemoteBotCapabilityOption[]; skills: AidenRemoteBotCapabilityOption[]; + /** Legacy omission means enabled; false temporarily suppresses saved grants. */ + skillsEnabled?: boolean; otherCapabilities: AidenRemoteBotCapabilityOption[]; notice: AidenRemoteBotAccessNoticeStatus; } @@ -2052,6 +2054,9 @@ export function parseAidenRemoteBotCapabilityCatalog( shellAvailable: requiredBooleanValue(value.shellAvailable, "Bot catalog shellAvailable"), connections, skills, + ...(value.skillsEnabled === undefined ? {} : { + skillsEnabled: requiredBooleanValue(value.skillsEnabled, "Bot catalog skillsEnabled"), + }), otherCapabilities, notice: parseBotAccessNoticeStatus(value.notice), }; @@ -2141,7 +2146,11 @@ function validateBotSelectionAgainstCatalog( }; requireCatalogOption(selection.fileScopeIds, catalog.fileScopes, "file scope"); requireCatalogOption(selection.connectionIds, catalog.connections, "connection"); - requireCatalogOption(selection.skillIds, catalog.skills, "skill"); + requireCatalogOption( + selection.skillIds, + catalog.skills.map((option) => ({ ...option, available: option.available || catalog.skillsEnabled === false })), + "skill", + ); requireCatalogOption( selection.otherCapabilityIds, catalog.otherCapabilities, diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index dac29856..76b1ac18 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -377,15 +377,21 @@ async function fixture(options: { calls.push(`bots:restore:${deviceId}:${botId}:${revision}:${key}`); return { ...botDetail, id: botId, revision: "bot_revision_3" }; }, - capabilityCatalog: async (deviceId) => { - calls.push(`bots:catalog:${deviceId}`); + capabilityCatalog: async (deviceId, botId) => { + calls.push(`bots:catalog:${deviceId}:${botId ?? "generic"}`); + if (botId === "bot-missing") { + throw new AidenRemoteServiceError("not_found", "This Bot no longer exists.", 404); + } return { revision: "bot_catalog_revision_1", providers: [], fileScopes: [], shellAvailable: true, connections: [], - skills: [], + skills: botId === undefined + ? [] + : [{ id: `skill_saved_${botId}`, label: "Saved skill", available: false }], + skillsEnabled: false, otherCapabilities: [], notice, }; @@ -989,7 +995,7 @@ test("authenticated Bot routes enforce the frozen CRUD, access, chat, and favori assert.deepEqual(app.calls.filter((call) => call.startsWith("bots:")), [ "bots:list:true", - "bots:catalog:device-authorized-12345678", + "bots:catalog:device-authorized-12345678:generic", "bots:favorites:get", "bots:create:device-authorized-12345678:bot-create-key-0001", "bots:get:bot-1", @@ -1007,6 +1013,53 @@ test("authenticated Bot routes enforce the frozen CRUD, access, chat, and favori } }); +test("Bot capability catalogs strictly route optional authenticated Bot targets", async () => { + const app = await fixture({ capabilities: ["bot:read"] }); + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + try { + const generic = await fetch(`${app.base}/bot-capabilities`, { headers }); + assert.equal(generic.status, 200); + const genericBody = await generic.json(); + assert.equal(genericBody.skillsEnabled, false); + assert.deepEqual(genericBody.skills, []); + + const target = await fetch(`${app.base}/bot-capabilities?botId=bot-1`, { headers }); + assert.equal(target.status, 200); + assert.deepEqual((await target.json()).skills, [ + { id: "skill_saved_bot-1", label: "Saved skill", available: false }, + ]); + assert.ok(app.calls.includes("bots:catalog:device-authorized-12345678:bot-1")); + + const otherTarget = await fetch(`${app.base}/bot-capabilities?botId=bot-2`, { headers }); + assert.equal(otherTarget.status, 200); + assert.deepEqual((await otherTarget.json()).skills, [ + { id: "skill_saved_bot-2", label: "Saved skill", available: false }, + ]); + + for (const query of [ + "botId=bot-1&botId=bot-2", + "target=bot-1", + "botId=", + `botId=${"b".repeat(161)}`, + "botId=bot/id", + "botId=bot-1&", + ]) { + const response = await fetch(`${app.base}/bot-capabilities?${query}`, { headers }); + assert.equal(response.status, 400, query); + assert.equal((await response.json()).error.code, "invalid_request", query); + } + + const missing = await fetch(`${app.base}/bot-capabilities?botId=bot-missing`, { headers }); + assert.equal(missing.status, 404); + assert.equal((await missing.json()).error.code, "not_found"); + } finally { + await app.close(); + } +}); + test("Bot inbox and avatar routes preserve device grants, approval ownership, and binary headers", async () => { const app = await fixture({ capabilities: ["bot:read", "bot:write", "chat:read"], diff --git a/main/services/aiden-remote-router.ts b/main/services/aiden-remote-router.ts index 35a8c93b..03d9815c 100644 --- a/main/services/aiden-remote-router.ts +++ b/main/services/aiden-remote-router.ts @@ -795,6 +795,33 @@ function includeArchivedBotsQuery(query: string): boolean { ); } +function botCapabilityCatalogQuery(query: string): string | undefined { + if (!query) return undefined; + const components = query.split("&"); + if (components.length !== 1) { + throw new AidenRemoteServiceError( + "invalid_request", + "The Bot capability catalog query is invalid.", + 400, + ); + } + const component = components[0]!; + const separator = component.indexOf("="); + const botId = separator < 0 ? "" : component.slice(separator + 1); + if ( + component.slice(0, separator) !== "botId" || + botId.includes("=") || + !/^[A-Za-z0-9._:-]{1,160}$/u.test(botId) + ) { + throw new AidenRemoteServiceError( + "invalid_request", + "The Bot capability catalog query is invalid.", + 400, + ); + } + return botId; +} + function botConversationsQuery(query: string): AidenRemoteBotConversationQuery { if (!query) return {}; const params = new URLSearchParams(query); @@ -1029,14 +1056,14 @@ export function createAidenRemoteRequestHandler( return; } if (path === "/bot-capabilities" && request.method === "GET") { - requireNoQuery(query); + const botId = botCapabilityCatalogQuery(query); route = "botCapabilities"; const device = await authenticate(request, dependencies.devices, "bot:read"); deviceIdSuffix = device.id.slice(-8); if (!dependencies.bots) { throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); } - writeJson(response, 200, await dependencies.bots.capabilityCatalog(device.id)); + writeJson(response, 200, await dependencies.bots.capabilityCatalog(device.id, botId)); return; } if (path === "/bot-favorites" && request.method === "GET") { diff --git a/main/services/bot-application-service.test.ts b/main/services/bot-application-service.test.ts index cf3582d8..7e455e18 100644 --- a/main/services/bot-application-service.test.ts +++ b/main/services/bot-application-service.test.ts @@ -2082,3 +2082,28 @@ test("delete-chat recovery is idempotent at every durable checkpoint", async (t) }); } }); + + +test("disabled Full Bot catalog retains only its own saved chat-reduction skill IDs as presentation tombstones", async () => { + const app = fixture({ bots: [bot("bot:one"), bot("bot:other")] }); + await app.service.initialize(); + const own = await app.service.createChat({ audienceId: "device:a", botId: "bot:one" }); + const other = await app.service.createChat({ audienceId: "device:a", botId: "bot:other" }); + for (const [chat, skillId] of [[own, "skill:saved"], [other, "skill:other-bot"]] as const) { + app.chatPolicies.set(chat.id, { + ...app.chatPolicies.get(chat.id)!, mode: "custom", + custom: { providerId: "provider:opaque", modelId: "model:opaque", fileScopeIds: ["scope:home"], + shellEnabled: false, connectionIds: [], skillIds: [skillId], otherCapabilityIds: [] }, + }); + } + const paused = catalog(); + paused.catalog.skillsEnabled = false; + paused.catalog.providers[0]!.models[0]!.supportsImages = false; + app.deps.catalog.snapshot = async () => paused; + const result = await app.service.capabilityCatalog("device:a", "bot:one"); + assert.equal(result.skillsEnabled, false); + assert.deepEqual(result.skills, [{ id: "skill:saved", label: "Saved skill", available: false }]); + assert.deepEqual(paused.resources.skills, [], "presentation IDs never become executable resources"); + assert.deepEqual((await app.service.capabilityCatalog("device:a")).skills, []); + assert.doesNotMatch(JSON.stringify(result), /other-bot|sourceId|instructions/u); +}); diff --git a/main/services/bot-application-service.ts b/main/services/bot-application-service.ts index a385681c..32949a00 100644 --- a/main/services/bot-application-service.ts +++ b/main/services/bot-application-service.ts @@ -10,6 +10,7 @@ import type { BotDefinition, BotUpdateInput, } from "../../renderer/shared/bots.js"; +import { finalizeBotCapabilityCatalog } from "./bot-capability-catalog-core.js"; import type { BotCapabilityCatalogMainService } from "./bot-capability-catalog-main.js"; import { retainedBotProviderForChat, @@ -1209,12 +1210,14 @@ export function createBotApplicationService(deps: BotApplicationDependencies) { input.access, snapshot.catalog.revision, ); + const savedBinding = await deps.capabilityStore.getBotBinding(input.botId); const binding = access.accessMode === "custom" ? await deps.catalog.bindCustom({ audienceId: input.audienceId, botId: input.botId, selection: access.custom, catalogRevision: access.catalogRevision, + retainedBindings: savedBinding ? [savedBinding] : [], snapshot, }) : undefined; @@ -1488,6 +1491,25 @@ export function createBotApplicationService(deps: BotApplicationDependencies) { ], }), }); + if (botId !== undefined && snapshot.catalog.skillsEnabled === false) { + // Full Bots have no exact Custom binding, but their existing chat + // reductions still own saved skill IDs. Retain safe presentation-only + // tombstones so native readers/editors can preserve those reductions. + // Never turn these IDs into runtime resources or new positive grants. + const chats = await deps.chatStore.listByBot(botId); + const policies = await Promise.all(chats.map(({ id }) => deps.capabilityStore.getChatPolicy(id))); + const skills = new Map(snapshot.catalog.skills.map((option) => [option.id, option])); + for (const policy of policies) { + if (policy.botId !== botId || policy.mode !== "custom") continue; + for (const id of policy.custom.skillIds) { + if (!skills.has(id)) skills.set(id, { id, label: "Saved skill", available: false }); + } + } + return finalizeBotCapabilityCatalog({ + ...snapshot.catalog, + skills: [...skills.values()].sort((left, right) => left.id.localeCompare(right.id)), + }); + } return snapshot.catalog; }, diff --git a/main/services/bot-capability-bindings.test.ts b/main/services/bot-capability-bindings.test.ts index ab3fa80c..ba065907 100644 --- a/main/services/bot-capability-bindings.test.ts +++ b/main/services/bot-capability-bindings.test.ts @@ -204,6 +204,33 @@ test("Custom selection binds exact provider, file, shell, MCP tool, skill, and o assert.doesNotThrow(() => assertBoundBotCustomSelectionCurrent(bound, current)); }); +test("a closed global Skills gate suppresses only skill drift validation", () => { + const current = snapshot(); + const bound = binding(current); + const withoutSkills = inventory(); + withoutSkills.skills = []; + const disabledSnapshot = withBotCapabilityTombstones( + buildBotCapabilityCatalogSnapshot({ + inventory: withoutSkills, + notice, + mintOpaqueId: createBotCapabilityOpaqueIdMint(key), + }), + [bound], + ); + + assert.throws( + () => assertBoundBotCustomSelectionCurrent(bound, disabledSnapshot), + BotCapabilityBindingDriftError, + ); + assert.doesNotThrow(() => + assertBoundBotCustomSelectionCurrent(bound, disabledSnapshot, { skillsEnabled: false }), + ); + assert.deepEqual( + botCustomSelectionDrift(bound, disabledSnapshot, { skillsEnabled: false }), + [], + ); +}); + test("file choices enforce Full Mac exclusivity and approved-location Bot folder pairing", () => { const current = snapshot(); const selection = fullSelection(current); diff --git a/main/services/bot-capability-bindings.ts b/main/services/bot-capability-bindings.ts index 79eac9a3..f93bc13e 100644 --- a/main/services/bot-capability-bindings.ts +++ b/main/services/bot-capability-bindings.ts @@ -1071,6 +1071,8 @@ export function bindBotCustomSelection(input: { selection: unknown; catalogRevision: string; snapshot: BotCapabilityCatalogSnapshot; + /** Main-owned current binding, never supplied by the editing client. */ + retainedBinding?: BoundBotCustomSelection; }): BoundBotCustomSelection { if (input.catalogRevision !== input.snapshot.catalog.revision) { throw new BotCapabilityValidationError( @@ -1078,7 +1080,10 @@ export function bindBotCustomSelection(input: { ); } const selection = parseBotCustomSelection(input.selection); - validateSelectionAgainstCatalog(selection, input.snapshot.catalog); + const retained = input.retainedBinding && parseBoundBotCustomSelection(input.retainedBinding); + validateSelectionAgainstCatalog(selection, input.snapshot.catalog, { + retainedSkillIds: retained?.selection.skillIds, + }); if (!fileSelectionIsCoherent(selection, input.snapshot)) { throw new BotCapabilityValidationError(BOT_FILE_SCOPE_SELECTION_GUIDANCE); } @@ -1106,7 +1111,9 @@ export function bindBotCustomSelection(input: { selection.connectionIds, "connection", ); - const skills = byOptionId(input.snapshot.resources.skills, selection.skillIds, "skill"); + const skills = input.snapshot.catalog.skillsEnabled === false + ? (retained?.skills ?? []).filter(({ option }) => selection.skillIds.includes(option.id)) + : byOptionId(input.snapshot.resources.skills, selection.skillIds, "skill"); const otherCapabilities = byOptionId( input.snapshot.resources.otherCapabilities, selection.otherCapabilityIds, @@ -1247,6 +1254,7 @@ function findOrAdoptByIdentity( export function botCustomSelectionDrift( binding: BoundBotCustomSelection, current: BotCapabilityCatalogSnapshot, + options: { skillsEnabled?: boolean } = {}, ): BotCapabilityDriftIssue[] { binding = parseBoundBotCustomSelection(binding); const issues: BotCapabilityDriftIssue[] = []; @@ -1306,12 +1314,14 @@ export function botCustomSelectionDrift( : resourceIssue("connection", bound.option.id, bound.exactFingerprint, found); if (foundIssue) issues.push(foundIssue); } - for (const bound of binding.skills) { - const found = findByIdOrSource(current.resources.skills, bound.option.id, bound.sourceId); - const foundIssue = found?.sourceId !== bound.sourceId - ? issue("skill", bound.option.id, "changed_or_removed") - : resourceIssue("skill", bound.option.id, bound.exactFingerprint, found); - if (foundIssue) issues.push(foundIssue); + if ((options.skillsEnabled ?? current.catalog.skillsEnabled) !== false) { + for (const bound of binding.skills) { + const found = findByIdOrSource(current.resources.skills, bound.option.id, bound.sourceId); + const foundIssue = found?.sourceId !== bound.sourceId + ? issue("skill", bound.option.id, "changed_or_removed") + : resourceIssue("skill", bound.option.id, bound.exactFingerprint, found); + if (foundIssue) issues.push(foundIssue); + } } for (const bound of binding.otherCapabilities) { const found = current.resources.otherCapabilities.find( @@ -1336,8 +1346,9 @@ export function botCustomSelectionDrift( export function assertBoundBotCustomSelectionCurrent( binding: BoundBotCustomSelection, current: BotCapabilityCatalogSnapshot, + options: { skillsEnabled?: boolean } = {}, ): void { - const issues = botCustomSelectionDrift(binding, current); + const issues = botCustomSelectionDrift(binding, current, options); if (issues.length > 0) throw new BotCapabilityBindingDriftError(issues); } @@ -1477,6 +1488,7 @@ export function withBotCapabilityTombstones( resources.skills.sort((left, right) => compareText(left.option.id, right.option.id)); resources.otherCapabilities.sort((left, right) => compareText(left.option.id, right.option.id)); const catalog = finalizeBotCapabilityCatalog({ + ...(current.catalog.skillsEnabled === undefined ? {} : { skillsEnabled: current.catalog.skillsEnabled }), providers: resources.providers.map(({ option }) => structuredClone(option)), fileScopes: resources.fileScopes.map(({ option }) => ({ ...option })), shellAvailable: resources.shell.available, diff --git a/main/services/bot-capability-catalog-core.ts b/main/services/bot-capability-catalog-core.ts index e856f3db..5c75f278 100644 --- a/main/services/bot-capability-catalog-core.ts +++ b/main/services/bot-capability-catalog-core.ts @@ -946,6 +946,7 @@ export function finalizeBotCapabilityCatalog(input: { shellAvailable: boolean; connections: BotCapabilityOption[]; skills: BotCapabilityOption[]; + skillsEnabled?: boolean; otherCapabilities: BotCapabilityOption[]; notice: BotNoticeStatus; }): BotCapabilityCatalog { @@ -955,6 +956,7 @@ export function finalizeBotCapabilityCatalog(input: { shellAvailable: input.shellAvailable, connections: input.connections, skills: input.skills, + ...(input.skillsEnabled === undefined ? {} : { skillsEnabled: input.skillsEnabled }), otherCapabilities: input.otherCapabilities, }; const catalog: BotCapabilityCatalog = { @@ -1037,11 +1039,13 @@ export function assertSafeBotCapabilityCatalogProjection( "shellAvailable", "connections", "skills", + "skillsEnabled", "otherCapabilities", "notice", ]); if ( - Object.keys(catalog).length !== 8 || + Object.keys(catalog).length !== (catalog.skillsEnabled === undefined ? 8 : 9) || + (catalog.skillsEnabled !== undefined && typeof catalog.skillsEnabled !== "boolean") || !isPathSafeBotCapabilityId(catalog.revision) || typeof catalog.shellAvailable !== "boolean" ) { @@ -1173,6 +1177,7 @@ export function assertSafeBotCapabilityCatalogProjection( shellAvailable: catalog.shellAvailable, connections: catalog.connections as BotCapabilityOption[], skills: catalog.skills as BotCapabilityOption[], + ...(catalog.skillsEnabled === undefined ? {} : { skillsEnabled: catalog.skillsEnabled as boolean }), otherCapabilities: catalog.otherCapabilities as BotCapabilityOption[], }); if (catalog.revision !== expectedRevision) { diff --git a/main/services/bot-capability-catalog-main.ts b/main/services/bot-capability-catalog-main.ts index 99f8fa0b..073b1bfb 100644 --- a/main/services/bot-capability-catalog-main.ts +++ b/main/services/bot-capability-catalog-main.ts @@ -7,6 +7,7 @@ import { } from "../../renderer/shared/bot-capabilities.js"; import { buildBotCapabilityCatalogSnapshot, + finalizeBotCapabilityCatalog, type BotCapabilityCatalogSnapshot, type BotConnectionInventory, type BotOrdinaryCapabilityInventory, @@ -67,6 +68,7 @@ export interface BotCapabilityInventoryPorts { inspectMacFiles(signal: AbortSignal): Promise; inspectShell(signal: AbortSignal): Promise; inspectConnections(signal: AbortSignal): Promise; + skillsEnabled?(): Promise; inspectSkills( signal: AbortSignal, target?: { botId: string }, @@ -141,6 +143,7 @@ export class BotCapabilityCatalogMainService { connections, skills, otherCapabilities, + skillsEnabled, ] = await Promise.all([ this.selectionKey(), input.notice, @@ -162,6 +165,7 @@ export class BotCapabilityCatalogMainService { input.botId === undefined ? undefined : { botId: this.botId(input.botId) }, ), this.ports.inspectOtherCapabilities(controller.signal), + this.ports.skillsEnabled?.() ?? Promise.resolve(true), ]); if (parent.aborted) throw abortReason(parent); const current = buildBotCapabilityCatalogSnapshot({ @@ -197,6 +201,10 @@ export class BotCapabilityCatalogMainService { notice, mintOpaqueId: createBotCapabilityOpaqueIdMint(selectionKey), }); + if (!skillsEnabled) { + current.resources.skills = []; + current.catalog = finalizeBotCapabilityCatalog({ ...current.catalog, skills: [], skillsEnabled: false }); + } const mintOpaqueId = createBotCapabilityOpaqueIdMint(selectionKey); for (const binding of input.retainedBindings ?? []) { assertBoundBotCustomSelectionOpaqueIds(binding, mintOpaqueId); @@ -275,6 +283,7 @@ export class BotCapabilityCatalogMainService { selection: input.selection, catalogRevision: input.catalogRevision, snapshot, + retainedBinding: input.retainedBindings?.[0], }); } diff --git a/main/services/bot-capability-inventory-ports.ts b/main/services/bot-capability-inventory-ports.ts index fd1a0fa7..fc10af5d 100644 --- a/main/services/bot-capability-inventory-ports.ts +++ b/main/services/bot-capability-inventory-ports.ts @@ -380,8 +380,17 @@ export function createBotCapabilityInventoryPorts( if (signal.aborted) throw signal.reason; return connectionInventory(servers, scopes); }, + async skillsEnabled() { + return (await dependencies.getSettings()).skillsEnabled !== false; + }, async inspectSkills(signal, target) { + if (signal.aborted) throw signal.reason; + // A global pause is not removal: do not tombstone durable incarnations or + // discover instructions until the user enables Skills again. + if ((await dependencies.getSettings()).skillsEnabled === false) return []; const resolved = await dependencies.listSkills(target); + if ((await dependencies.getSettings()).skillsEnabled === false) return []; + if (signal.aborted) throw signal.reason; const partitions = new Set([ "global", ...(target ? [`bot:${target.botId}`] : []), diff --git a/main/services/bot-capability-production-shape.test.ts b/main/services/bot-capability-production-shape.test.ts index 99bf0113..60c5902b 100644 --- a/main/services/bot-capability-production-shape.test.ts +++ b/main/services/bot-capability-production-shape.test.ts @@ -281,3 +281,97 @@ test("shipping Bot inventory is wired to canonical Pi providers and model drift assert.match(models, /withBotProviderInventoryMutation\(async \(\) =>/u); assert.match(models, /\}, invalidateBotRuntimeInventoryAuthority\)/u); }); + +test("global Skills pause preserves real incarnations and exact saved Bot grants across catalog reads, edits, and restart", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-bot-paused-skills-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + let skillsEnabled = true; + let instruction = "Private unchanged instructions"; + let discoveries = 0; + let randomCounter = 0; + const createServices = async () => { + const store = createBotCapabilityStore({ + root: () => root, + mintRevision: (kind, sequence) => `revision:${kind}:${sequence}`, + mintIncarnation: () => Buffer.alloc(32, ++randomCounter).toString("base64url"), + }); + await store.initialize(); + const catalog = createBotCapabilityCatalogMainService(createBotCapabilityInventoryPorts({ + loadOpaqueSelectionKey: async () => Buffer.alloc(32, 21), + loadNoticeStatus: (audience) => store.noticeStatus(audience), + listProviders: async () => [{ id: "provider", kind: "openai", label: "Provider", baseUrl: "", models: ["chat"], needsKey: false, hasKey: false }], + providerCredentialSignature: async () => hash("credential"), + listMcpServers: async () => [], + inspectMcpScopes: async () => [], + listSkills: async (target) => { + discoveries += 1; + return [ + { sourceId: "global-skill", label: "Global", description: "Global skill", instructions: instruction, available: true }, + ...(target ? [{ sourceId: `private-${target.botId}`, label: "Private", description: "Bot skill", instructions: instruction, available: true, incarnationPartition: `bot:${target.botId}` }] : []), + ]; + }, + listApprovedLocations: async () => [], + incarnations: createBotCapabilityIncarnationStore(store), + getSettings: async () => ({ skillsEnabled }), + webSearchAvailability: async () => ({ ready: false }), + subagentsAvailable: () => false, + })); + return { store, catalog }; + }; + let { store, catalog } = await createServices(); + const audienceId = "device_a"; + const botId = "bot:paused"; + await store.acknowledgeNotice(audienceId, { + version: BOT_FULL_ACCESS_NOTICE_VERSION, + decision: "customize_first", + confirmedForeground: true, + }); + const enabled = await catalog.snapshot({ audienceId, botId }); + const custom = { + providerId: enabled.catalog.providers[0]!.id, + modelId: enabled.catalog.providers[0]!.models[0]!.id, + fileScopeIds: [enabled.catalog.fileScopes.find(({ kind }) => kind === "bot_home")!.id], + shellEnabled: false, + connectionIds: [], + skillIds: enabled.catalog.skills.map(({ id }) => id), + otherCapabilityIds: [], + }; + const saved = await catalog.bindCustom({ audienceId, botId, selection: custom, catalogRevision: enabled.catalog.revision, snapshot: enabled }); + const policy = await store.createBotPolicy({ botId, catalog: enabled.catalog, access: { accessMode: "custom", custom, catalogRevision: enabled.catalog.revision }, binding: saved }); + const chat = await store.createChatPolicy({ botId, chatId: "chat:paused", expectedBotPolicyRevision: policy.revision, catalog: enabled.catalog }); + const discoveryCount = discoveries; + skillsEnabled = false; + const paused = await catalog.snapshot({ audienceId, botId, retainedBindings: [saved] }); + assert.equal(paused.catalog.skillsEnabled, false); + assert.ok(paused.catalog.skills.every(({ available }) => !available)); + assert.equal(discoveries, discoveryCount, "off must not read skill instructions"); + assert.deepEqual((await catalog.snapshot({ audienceId })).catalog.skills, []); + // Audience preflight, archived reads, and admission all consume the same + // main-owned suppression bit instead of requiring each caller to guess it. + await store.assertAuthorityBindingsCurrent({ botId, chatId: chat.chatId, snapshot: paused }); + const admitted = await store.admit({ audienceId, botId, chatId: chat.chatId, snapshot: paused }); + admitted.lease.release(); + const edited = { ...custom, shellEnabled: true }; + const retained = await catalog.bindCustom({ audienceId, botId, selection: edited, catalogRevision: paused.catalog.revision, snapshot: paused, retainedBindings: [saved] }); + assert.deepEqual(retained.skills, saved.skills, "unrelated edit retains exact fingerprints"); + const updated = await store.updateBotPolicy({ botId, expectedRevision: policy.revision, catalog: paused.catalog, access: { accessMode: "custom", custom: edited, catalogRevision: paused.catalog.revision }, binding: retained }); + await store.updateChatPolicy({ chatId: chat.chatId, expectedRevision: chat.revision, catalog: paused.catalog, access: { mode: "custom", custom, expectedBotPolicyRevision: updated.revision, catalogRevision: paused.catalog.revision } }); + await assert.rejects(catalog.bindCustom({ audienceId, botId, selection: edited, catalogRevision: paused.catalog.revision, snapshot: paused }), /disabled/u); + await assert.rejects(catalog.bindCustom({ audienceId, botId, selection: { ...edited, skillIds: [...edited.skillIds, "skill:unknown"] }, catalogRevision: paused.catalog.revision, snapshot: paused, retainedBindings: [saved] }), /disabled/u); + await store.archiveBotAuthority(botId); + await store.assertAuthorityBindingsCurrent({ botId, chatId: chat.chatId, snapshot: paused }); + assert.equal((await store.inspectArchivedReadAuthority(botId, chat.chatId)).policy.authorityStatus, "archived"); + await store.restoreBotAuthority(botId); + ({ store, catalog } = await createServices()); + skillsEnabled = true; + const resumed = await catalog.snapshot({ audienceId, botId, retainedBindings: [retained] }); + assert.deepEqual(resumed.resources.skills, enabled.resources.skills, "disable/read/restart/enable must not churn incarnations"); + const resumedAdmission = await store.admit({ audienceId, botId, chatId: chat.chatId, snapshot: resumed }); + resumedAdmission.lease.release(); + skillsEnabled = false; + await catalog.snapshot({ audienceId, botId, retainedBindings: [retained] }); + instruction = "Changed while paused"; + skillsEnabled = true; + const changed = await catalog.snapshot({ audienceId, botId, retainedBindings: [retained] }); + await assert.rejects(store.admit({ audienceId, botId, chatId: chat.chatId, snapshot: changed }), /changed|current|capabilit/iu); +}); diff --git a/main/services/bot-capability-services-main.ts b/main/services/bot-capability-services-main.ts index 9ee502c4..3dcb867d 100644 --- a/main/services/bot-capability-services-main.ts +++ b/main/services/bot-capability-services-main.ts @@ -73,6 +73,7 @@ export const botSkillContentWatcher = new BotSkillContentWatcher(() => { /** Main-only join from exact Bot grants to the existing runtime skill registry. */ export async function resolveBotRuntimeSkills(botId: string) { const skills = await resolveBotRuntimeSkillBindings({ + isEnabled: async () => (await configStore.getSettings()).skillsEnabled !== false, loadIdentityKey: () => opaqueKeyStore.load(), listConfigured: () => configStore.listSkills(), botId, @@ -170,6 +171,7 @@ export const botCapabilityCatalog = createBotCapabilityCatalogMainService( }), listSkills: (target) => resolveBotCapabilitySkills({ + isEnabled: async () => (await configStore.getSettings()).skillsEnabled !== false, loadIdentityKey: () => opaqueKeyStore.load(), listConfigured: () => configStore.listSkills(), ...(target diff --git a/main/services/bot-capability-store-core.test.ts b/main/services/bot-capability-store-core.test.ts index c3484ba0..02a926ea 100644 --- a/main/services/bot-capability-store-core.test.ts +++ b/main/services/bot-capability-store-core.test.ts @@ -225,6 +225,13 @@ test("Custom policy bindings are mandatory, private in projections, strict on di () => editor.assertAuthorityBindingsCurrent({ botId: "bot:one", snapshot: drifted }), BotCapabilityBindingDriftError, ); + assert.doesNotThrow(() => + editor.assertAuthorityBindingsCurrent({ + botId: "bot:one", + snapshot: drifted, + skillsEnabled: false, + }), + ); const futureBinding = structuredClone(state) as unknown as { policies: Array<{ binding: { version: number } }>; diff --git a/main/services/bot-capability-store-core.ts b/main/services/bot-capability-store-core.ts index e916f16d..537da20c 100644 --- a/main/services/bot-capability-store-core.ts +++ b/main/services/bot-capability-store-core.ts @@ -1181,7 +1181,9 @@ export class BotCapabilityStateEditor { const access = parseBotAccessUpdate(input.access); this.assertCatalog(input.catalog, access.catalogRevision); if (access.accessMode === "custom") { - validateSelectionAgainstCatalog(access.custom, input.catalog); + validateSelectionAgainstCatalog(access.custom, input.catalog, { + retainedSkillIds: policy.accessMode === "custom" ? policy.custom.skillIds : [], + }); } validateVisionSelectionAgainstCatalog(access.visionModel, input.catalog); const binding = this.bindingForCustomAccess(access, input.binding); @@ -1336,7 +1338,9 @@ export class BotCapabilityStateEditor { } const custom = input.custom === undefined ? undefined : parseBotCustomSelection(input.custom); if (custom) { - validateSelectionAgainstCatalog(custom, input.catalog); + validateSelectionAgainstCatalog(custom, input.catalog, { + retainedSkillIds: policy.accessMode === "custom" ? policy.custom.skillIds : [], + }); if ( policy.accessMode === "custom" && !botCustomSelectionIsSubset(custom, policy.custom, input.catalog.fileScopes) @@ -1376,7 +1380,10 @@ export class BotCapabilityStateEditor { this.assertCatalog(input.catalog, access.catalogRevision); this.assertPolicyRevision(policy, access.expectedBotPolicyRevision); if (access.mode === "custom") { - validateSelectionAgainstCatalog(access.custom, input.catalog); + validateSelectionAgainstCatalog(access.custom, input.catalog, { + retainedSkillIds: chat.mode === "custom" ? chat.custom.skillIds + : policy.accessMode === "custom" ? policy.custom.skillIds : [], + }); const model = storedBotModelAuthority(policy); if ( model && @@ -1676,6 +1683,7 @@ export class BotCapabilityStateEditor { botId: string; chatId?: string; snapshot: BotCapabilityCatalogSnapshot; + skillsEnabled?: boolean; }): BoundBotCustomSelection | undefined { const policy = this.policy(input.botId); const model = storedBotModelAuthority(policy); @@ -1686,13 +1694,18 @@ export class BotCapabilityStateEditor { assertBoundBotProviderModelCurrent(policy.visionModel.binding, input.snapshot); } if (policy.accessMode === "custom") { - assertBoundBotCustomSelectionCurrent(policy.binding, input.snapshot); + assertBoundBotCustomSelectionCurrent(policy.binding, input.snapshot, { + skillsEnabled: input.skillsEnabled ?? input.snapshot.catalog.skillsEnabled, + }); } if (input.chatId) { const chat = this.chat(input.chatId); if (chat.botId !== policy.botId) throw new BotCapabilityUnavailableError(); if (chat.mode === "custom") { - validateSelectionAgainstCatalog(chat.custom, input.snapshot.catalog); + validateSelectionAgainstCatalog( + (input.skillsEnabled ?? input.snapshot.catalog.skillsEnabled) === false ? { ...chat.custom, skillIds: [] } : chat.custom, + input.snapshot.catalog, + ); } } return policy.accessMode === "custom" diff --git a/main/services/bot-capability-store.ts b/main/services/bot-capability-store.ts index 3f3a9266..f99e16f3 100644 --- a/main/services/bot-capability-store.ts +++ b/main/services/bot-capability-store.ts @@ -382,7 +382,9 @@ export class BotCapabilityStore { throw new BotCapabilityCatalogConflictError(input.catalog.revision); } if (access.accessMode === "custom") { - validateSelectionAgainstCatalog(access.custom, input.catalog); + validateSelectionAgainstCatalog(access.custom, input.catalog, { + retainedSkillIds: policy.accessMode === "custom" ? policy.custom.skillIds : [], + }); const binding = parseBoundBotCustomSelection(input.binding); if ( binding.catalogRevision !== access.catalogRevision || @@ -500,7 +502,10 @@ export class BotCapabilityStore { throw new BotCapabilityRevisionConflictError(policy.revision); } if (access.mode === "custom") { - validateSelectionAgainstCatalog(access.custom, input.catalog); + validateSelectionAgainstCatalog(access.custom, input.catalog, { + retainedSkillIds: chat.mode === "custom" ? chat.custom.skillIds + : policy.accessMode === "custom" ? policy.custom.skillIds : [], + }); if ( policy.accessMode === "custom" && !botCustomSelectionIsSubset(access.custom, policy.custom) @@ -580,6 +585,8 @@ export class BotCapabilityStore { chatId?: string; /** Required whenever the effective authority is Custom. */ snapshot?: BotCapabilityCatalogSnapshot; + /** A closed global gate preserves durable grants while excluding them from runtime authority. */ + skillsEnabled?: boolean; }): Promise { this.requireInitialized(); return this.serialized(async () => { @@ -598,6 +605,7 @@ export class BotCapabilityStore { botId: input.botId, ...(input.chatId ? { chatId: input.chatId } : {}), snapshot: input.snapshot, + skillsEnabled: input.skillsEnabled, }); } const lease = this.leases.acquire({ @@ -634,6 +642,7 @@ export class BotCapabilityStore { botId: string; chatId?: string; snapshot: BotCapabilityCatalogSnapshot; + skillsEnabled?: boolean; }): Promise { this.requireInitialized(); return this.serialized(async () => diff --git a/main/services/bot-runtime-authority-main.ts b/main/services/bot-runtime-authority-main.ts index 268434eb..d62a91ad 100644 --- a/main/services/bot-runtime-authority-main.ts +++ b/main/services/bot-runtime-authority-main.ts @@ -14,6 +14,7 @@ import { import { botCapabilityFactsFingerprint } from "./bot-capability-catalog-core.js"; import * as fs from "node:fs/promises"; import { botRuntimeInventoryLeases } from "./bot-runtime-inventory-lease.js"; +import { configStore } from "./config-store.js"; const resolver = createBotRuntimeAuthorityResolver({ botStore, @@ -22,6 +23,7 @@ const resolver = createBotRuntimeAuthorityResolver({ catalog: botCapabilityCatalog, managedWorkspace: botManagedWorkspace, inventoryLeases: botRuntimeInventoryLeases, + skillsEnabled: async () => (await configStore.getSettings()).skillsEnabled !== false, }); export const BOT_DESKTOP_AUDIENCE_ID = "desktop:local"; diff --git a/main/services/bot-runtime-authority.test.ts b/main/services/bot-runtime-authority.test.ts index af089b7b..eb5bab58 100644 --- a/main/services/bot-runtime-authority.test.ts +++ b/main/services/bot-runtime-authority.test.ts @@ -3,7 +3,11 @@ import { createHash } from "node:crypto"; import test from "node:test"; import type { BotDefinition } from "../../renderer/shared/bots.js"; import type { BotCustomSelection } from "../../renderer/shared/bot-capabilities.js"; -import { bindBotCustomSelection, type BoundBotCustomSelection } from "./bot-capability-bindings.js"; +import { + bindBotCustomSelection, + withBotCapabilityTombstones, + type BoundBotCustomSelection, +} from "./bot-capability-bindings.js"; import { buildBotCapabilityCatalogSnapshot, type BotCapabilityCatalogSnapshot, @@ -187,16 +191,20 @@ function inventory(suffix = "v1"): BotCapabilityInventory { function snapshot(suffix = "v1"): BotCapabilityCatalogSnapshot { return buildBotCapabilityCatalogSnapshot({ inventory: inventory(suffix), - notice: { - version: "bot-full-access-v1", - requiresAcknowledgement: false, - acceptedAt: "2026-08-23T00:00:00.000Z", - acceptedDecision: "continue_full", - }, + notice: acceptedNotice(), mintOpaqueId: mint, }); } +function acceptedNotice() { + return { + version: "bot-full-access-v1" as const, + requiresAcknowledgement: false as const, + acceptedAt: "2026-08-23T00:00:00.000Z", + acceptedDecision: "continue_full" as const, + }; +} + function selection(current: BotCapabilityCatalogSnapshot, input: { connection?: boolean; skill?: boolean; @@ -306,6 +314,7 @@ function fixture(input: { chatWorkspaceId?: string; omitModelAuthority?: boolean; fullWebSearchEnabled?: boolean; + skillsEnabled?: boolean; } = {}) { const inventoryLeases = new BotRuntimeInventoryLeaseRegistry(); let currentSnapshot = input.currentSnapshot ?? snapshot(); @@ -335,6 +344,17 @@ function fixture(input: { }, binding: modelBinding, }; + if (input.skillsEnabled === false && botBinding) { + currentSnapshot = withBotCapabilityTombstones( + buildBotCapabilityCatalogSnapshot({ + inventory: { ...inventory(), skills: [] }, + notice: acceptedNotice(), + mintOpaqueId: mint, + }), + [botBinding], + ); + } + let skillsEnabled = input.skillsEnabled ?? true; let currentBot = bot(); let currentChat = { ...chat(), @@ -425,10 +445,12 @@ function fixture(input: { }, }, inventoryLeases, + skillsEnabled: async () => skillsEnabled, }; return { resolver: createBotRuntimeAuthorityResolver(deps), setSnapshot(value: BotCapabilityCatalogSnapshot) { currentSnapshot = value; }, + setSkillsEnabled(value: boolean) { skillsEnabled = value; }, invalidateLease() { leaseValid = false; }, archive() { currentBot = bot(true); }, replaceHome() { revalidateHomeError = new Error("home replaced"); }, @@ -529,6 +551,52 @@ test("a Custom chat reduction intersects the Bot ceiling and retains exact tool assert.equal(authority.files.botHome, true); }); +test("global Skills off suppresses Custom Bot skills without mutating saved grants", async () => { + const enabledSnapshot = snapshot(); + const app = fixture({ customBot: true, currentSnapshot: enabledSnapshot, skillsEnabled: false }); + const disabledAdmission = await app.resolver.admit({ + audienceId: "device-a", + botId: "bot-a", + chatId: "chat-a", + }); + assert.deepEqual(disabledAdmission.authority.skills, []); + await disabledAdmission.revalidateBeforeEffect(); + disabledAdmission.release(); + + app.setSnapshot(enabledSnapshot); + app.setSkillsEnabled(true); + const enabledAdmission = await app.resolver.admit({ + audienceId: "device-a", + botId: "bot-a", + chatId: "chat-a", + }); + assert.deepEqual(enabledAdmission.authority.skills.map(({ sourceId }) => sourceId), ["skill-a"]); + enabledAdmission.release(); +}); + +test("enabled Skills still fail closed when a saved Custom Bot skill is missing", async () => { + const enabledSnapshot = snapshot(); + const app = fixture({ customBot: true, currentSnapshot: enabledSnapshot }); + const binding = bindBotCustomSelection({ + selection: selection(enabledSnapshot, { skill: true }), + catalogRevision: enabledSnapshot.catalog.revision, + snapshot: enabledSnapshot, + }); + app.setSnapshot(withBotCapabilityTombstones( + buildBotCapabilityCatalogSnapshot({ + inventory: { ...inventory(), skills: [] }, + notice: acceptedNotice(), + mintOpaqueId: mint, + }), + [binding], + )); + await expectFailure(app.resolver.admit({ + audienceId: "device-a", + botId: "bot-a", + chatId: "chat-a", + }), "capability_changed"); +}); + test("a Full Bot with a Custom chat reduction keeps Custom access while using Bot model authority", async () => { const current = snapshot(); const app = fixture({ diff --git a/main/services/bot-runtime-authority.ts b/main/services/bot-runtime-authority.ts index 777f4a07..196b1de1 100644 --- a/main/services/bot-runtime-authority.ts +++ b/main/services/bot-runtime-authority.ts @@ -197,6 +197,7 @@ export interface BotRuntimeAuthorityDependencies { catalog: CatalogPort; managedWorkspace: ManagedWorkspacePort; inventoryLeases?: Pick; + skillsEnabled?(): Promise; } function fail(classification: BotRuntimeAuthorityFailure): never { @@ -298,11 +299,14 @@ function otherAuthority( function customBinding( admission: BotCapabilityAdmission, snapshot: BotCapabilityCatalogSnapshot, + skillsEnabled: boolean, ): BoundBotCustomSelection { if (!admission.effectiveCustom) fail("access_unavailable"); try { return bindBotCustomSelection({ - selection: admission.effectiveCustom, + selection: skillsEnabled + ? admission.effectiveCustom + : { ...admission.effectiveCustom, skillIds: [] }, catalogRevision: snapshot.catalog.revision, snapshot, }); @@ -335,6 +339,7 @@ function buildAuthority(input: { workspace: BotManagedWorkspaceResolution; admission: BotCapabilityAdmission; snapshot: BotCapabilityCatalogSnapshot; + skillsEnabled: boolean; }): BotRuntimeEffectiveAuthority { const { admission, snapshot, chat } = input; if (!admission.chat) fail("access_unavailable"); @@ -366,7 +371,9 @@ function buildAuthority(input: { connections = snapshot.resources.connections .filter(({ option }) => option.available) .map(connectionAuthority); - skills = snapshot.resources.skills.filter(({ option }) => option.available).map(skillAuthority); + skills = input.skillsEnabled + ? snapshot.resources.skills.filter(({ option }) => option.available).map(skillAuthority) + : []; otherCapabilities = snapshot.resources.otherCapabilities .filter( ({ kind, option }) => @@ -376,7 +383,7 @@ function buildAuthority(input: { ) .map(otherAuthority); } else { - const binding = customBinding(admission, snapshot); + const binding = customBinding(admission, snapshot, input.skillsEnabled); files = fileAuthority(binding.fileScopes); shell = binding.shell ? { @@ -508,6 +515,8 @@ export class BotRuntimeAuthorityResolver { let inventoryLease: BotRuntimeInventoryLease | undefined; try { inventoryLease = (this.deps.inventoryLeases ?? botRuntimeInventoryLeases).acquire(); + const skillsEnabled = await (this.deps.skillsEnabled?.() ?? Promise.resolve(true)); + inventoryLease.assertCurrent(); const { bot, chat } = await resolveIdentities(this.deps, input.botId, input.chatId); let workspace: BotManagedWorkspaceResolution; try { @@ -536,6 +545,7 @@ export class BotRuntimeAuthorityResolver { botId: input.botId, chatId: input.chatId, snapshot, + skillsEnabled, }); } catch (error) { if ( @@ -554,6 +564,7 @@ export class BotRuntimeAuthorityResolver { workspace, admission: capabilityAdmission, snapshot, + skillsEnabled, }); let released = false; const signal = AbortSignal.any([lease.signal, inventoryLease.signal]); @@ -600,6 +611,7 @@ export class BotRuntimeAuthorityResolver { botId: input.botId, chatId: input.chatId, snapshot: currentSnapshot, + skillsEnabled, }); } catch { fail("capability_changed"); diff --git a/main/services/bot-skill-inventory.test.ts b/main/services/bot-skill-inventory.test.ts index c8e45285..8696f067 100644 --- a/main/services/bot-skill-inventory.test.ts +++ b/main/services/bot-skill-inventory.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resolveBotCapabilitySkills } from "./bot-skill-inventory.js"; +import { resolveBotCapabilitySkills, resolveBotRuntimeSkillBindings } from "./bot-skill-inventory.js"; test("Bot skills resolve configured, global, and only the selected Bot home without paths", async () => { const discoveredRoots: Array = []; @@ -40,3 +40,17 @@ test("create-Bot skill inventory never discovers a managed Bot home", async () = assert.deepEqual(discoveredRoots, [undefined]); assert.deepEqual(skills.map(({ label }) => label), ["Global"]); }); + + +test("global disable withholds Bot catalogs and runtime bindings without reading skill content", async () => { + const unexpectedRead = async (): Promise => { throw new Error("Skill storage must not be read"); }; + const dependencies = { + isEnabled: async () => false, + loadIdentityKey: unexpectedRead, + listConfigured: unexpectedRead, + loadBotHomePath: unexpectedRead, + discover: unexpectedRead, + }; + assert.deepEqual(await resolveBotCapabilitySkills(dependencies), []); + assert.deepEqual(await resolveBotRuntimeSkillBindings(dependencies), []); +}); diff --git a/main/services/bot-skill-inventory.ts b/main/services/bot-skill-inventory.ts index 9eac8172..7046907f 100644 --- a/main/services/bot-skill-inventory.ts +++ b/main/services/bot-skill-inventory.ts @@ -9,6 +9,7 @@ import type { DiscoveredSkill, Skill } from "./types.js"; import type { BotResolvedSkill } from "./bot-capability-inventory-ports.js"; export interface BotSkillInventoryDependencies { + isEnabled?(): Promise; loadIdentityKey(): Promise; listConfigured(): Promise; botId?: string; @@ -51,6 +52,9 @@ function safeSourceId(key: Uint8Array, candidate: SkillRegistryCandidate): strin async function resolvedBotSkills( dependencies: BotSkillInventoryDependencies, ): Promise<{ key: Uint8Array; skills: readonly ResolvedSkillCandidate[] }> { + if (dependencies.isEnabled && !(await dependencies.isEnabled())) { + return { key: new Uint8Array(32), skills: [] }; + } const [key, configured, home, globalDiscovered] = await Promise.all([ dependencies.loadIdentityKey(), dependencies.listConfigured(), @@ -58,9 +62,15 @@ async function resolvedBotSkills( dependencies.discover(undefined), ]); if (key.byteLength !== 32) throw new Error("Bot skill identity key is invalid."); + if (dependencies.isEnabled && !(await dependencies.isEnabled())) { + return { key, skills: [] }; + } const workspaceDiscovered = home ? (await dependencies.discover(home)).filter(({ source }) => source === "workspace") : []; + if (dependencies.isEnabled && !(await dependencies.isEnabled())) { + return { key, skills: [] }; + } return { key, skills: resolveSkillCandidates([ @@ -77,14 +87,13 @@ export async function resolveBotCapabilitySkills( ): Promise { const { key, skills } = await resolvedBotSkills(dependencies); return skills.map((skill) => ({ - sourceId: safeSourceId(key, skill), - label: skill.name, - description: skill.description, - instructions: skill.instructions, - available: skill.available, - incarnationPartition: - skill.source === "workspace" ? `bot:${dependencies.botId}` : "global", - })); + sourceId: safeSourceId(key, skill), + label: skill.name, + description: skill.description, + instructions: skill.instructions, + available: skill.available, + incarnationPartition: skill.source === "workspace" ? `bot:${dependencies.botId}` : "global", + })); } export interface BotRuntimeResolvedSkill extends BotResolvedSkill { @@ -106,7 +115,6 @@ export async function resolveBotRuntimeSkillBindings( description: skill.description, instructions: skill.instructions, available: skill.available, - incarnationPartition: - skill.source === "workspace" ? `bot:${dependencies.botId}` : "global", + incarnationPartition: skill.source === "workspace" ? `bot:${dependencies.botId}` : "global", })); } diff --git a/main/services/config-store-core.test.ts b/main/services/config-store-core.test.ts index 8067f358..f513d10a 100644 --- a/main/services/config-store-core.test.ts +++ b/main/services/config-store-core.test.ts @@ -2868,3 +2868,18 @@ test("compaction preference defaults to LLM and survives a restart independently await next.setSettings({ compactionEngine: "llm" }); assert.equal((await next.getSettings()).compactionEngine, "llm"); }); + +test("global skills preference persists independently of individual skill choices", async (t) => { + const h = await harness(t); + assert.notEqual((await h.store.getSettings()).skillsEnabled, false); + const skill = { id: "saved-skill", name: "Review", description: "Review code", instructions: "Review carefully", enabled: true }; + await h.store.saveSkill(skill); + await h.store.setSettings({ skillsEnabled: false }); + await h.store.setSettings({ profileName: "Unrelated preference" }); + assert.equal((await h.store.getSettings()).skillsEnabled, false); + assert.equal((await readJson<{ settings: { skillsEnabled: boolean } }>(h.settingsFile)).settings.skillsEnabled, false); + assert.deepEqual(await h.store.listSkills(), [skill]); + await h.store.setSettings({ skillsEnabled: true }); + assert.equal((await h.store.getSettings()).skillsEnabled, true); + assert.deepEqual(await h.store.listSkills(), [skill]); +}); diff --git a/main/services/context-lifecycle-service-main.ts b/main/services/context-lifecycle-service-main.ts index 3c82c81d..46e71cad 100644 --- a/main/services/context-lifecycle-service-main.ts +++ b/main/services/context-lifecycle-service-main.ts @@ -24,6 +24,7 @@ export const contextLifecycleService = new ContextLifecycleService({ development: !isPackagedRuntime(), behaviorEnabled: piUpgradeBehaviorEnabledAtStartup, }), + skillsEnabled: async () => (await configStore.getSettings()).skillsEnabled !== false, getChat: (chatId) => chatStore.get(chatId), listChatsByBot: (botId) => chatStore.listByBot(botId), isBotArchived: async (botId) => (await botStore.get(botId))?.archivedAt !== undefined, diff --git a/main/services/context-lifecycle-service.test.ts b/main/services/context-lifecycle-service.test.ts index 3e090160..dfcaf42e 100644 --- a/main/services/context-lifecycle-service.test.ts +++ b/main/services/context-lifecycle-service.test.ts @@ -1,10 +1,25 @@ import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; +import { + createModels, + fauxAssistantMessage, + fauxProvider, + type Api, + type Model, +} from "@earendil-works/pi-ai"; import type { ChatTurnLease } from "./chat-turn-admission.js"; import { ContextLifecycleService, type ContextLifecycleServiceDeps, } from "./context-lifecycle-service.js"; +import type { ResolvedModelRuntime } from "./model-runtime-core.js"; +import { + PiCompactionSessionStore, + syncChatMessagesToPiSession, +} from "./pi-compaction-session-store.js"; import type { Chat } from "./types.js"; const baseChat: Chat = { @@ -43,9 +58,10 @@ function deps(overrides: Partial = {}) { throw new Error("stop after authority checks"); }, resolveRuntime: async () => - ({ provider: { id: "saved-provider" }, model: { id: "saved-model" } }) as Awaited< - ReturnType - >, + ({ + provider: { id: "saved-provider" }, + model: { id: "saved-model" }, + }) as Awaited>, resolveThinkingLevel: async () => "off", ...overrides, }; @@ -136,9 +152,10 @@ test("legacy Bot duplicates are read-only and never resolve provider state", asy test("manual compaction rejects a provider alias that changes the saved binding", async () => { const { value } = deps({ resolveRuntime: async () => - ({ provider: { id: "aliased-provider" }, model: { id: "saved-model" } }) as Awaited< - ReturnType - >, + ({ + provider: { id: "aliased-provider" }, + model: { id: "saved-model" }, + }) as Awaited>, }); assert.deepEqual( await new ContextLifecycleService(value).compactChat( @@ -249,3 +266,179 @@ test("explicit VCC uses offline metadata without invoking provider auth or think // The fixture intentionally fails on session open, after the offline authority check. assert.deepEqual(result, { compacted: false, reason: "compaction_failed" }); }); + +function validSummary(label: string): string { + return `## Goal\n${label}\n\n## Constraints & Preferences\n- none\n\n## Progress\n### Done\n- [x] checkpointed\n\n### In Progress\n- [ ] continue\n\n### Blocked\n- none\n\n## Key Decisions\n- preserve visible context\n\n## Next Steps\n1. Continue\n\n## Critical Context\n- ${label}`; +} + +function compactionRuntime(providerId: string, modelId: string) { + const faux = fauxProvider({ + api: "openai-completions", + provider: providerId, + models: [{ id: modelId, contextWindow: 8_000, maxTokens: 1_000 }], + }); + const models = createModels(); + models.setProvider(faux.provider); + const model = faux.getModel() as Model; + const runtime: ResolvedModelRuntime = { + provider: { + id: providerId, + kind: "openai", + label: "Compaction test", + baseUrl: "https://compaction.invalid/v1", + models: [modelId], + needsKey: false, + }, + model, + models, + apiKey: undefined, + headers: undefined, + streams: { + streamSimple: () => { + throw new Error("The registered faux provider must own compaction."); + }, + }, + }; + return { faux, model, runtime }; +} + +test("manual compaction excludes durable skill instructions while Skills is disabled", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-context-skill-gate-")); + t.after(() => rm(root, { recursive: true, force: true })); + const providerId = "context-skill-gate"; + const modelId = "context-skill-gate-model"; + const { faux, model, runtime } = compactionRuntime(providerId, modelId); + let providerObserved = false; + const providerPayloads: string[] = []; + faux.setResponses( + Array.from({ length: 4 }, () => (context) => { + providerPayloads.push(JSON.stringify(context)); + providerObserved = true; + return fauxAssistantMessage(validSummary("clean manual checkpoint")); + }), + ); + const chat: Chat = { + ...baseChat, + providerId, + model: modelId, + messages: Array.from({ length: 12 }, (_, index) => [ + { + id: `visible-user-${index}`, + role: "user" as const, + content: `${index === 0 ? "Visible operator request" : `Visible follow-up ${index}`} ${"x".repeat(8_000)}`, + createdAt: index * 2 + 10, + }, + { + id: `visible-assistant-${index}`, + role: "assistant" as const, + content: `Visible answer ${index}`, + createdAt: index * 2 + 11, + }, + ]).flat(), + }; + const store = new PiCompactionSessionStore({ root: async () => root }); + const session = await store.openChat(chat.id); + await syncChatMessagesToPiSession( + session, + chat.messages, + model, + false, + new Map([["visible-user-0", "HIDDEN_SKILL_INSTRUCTIONS"]]), + ); + const { value } = deps({ + getChat: async () => chat, + skillsEnabled: async () => false, + openSession: async () => session, + resolveRuntime: async () => runtime, + }); + + const result = await new ContextLifecycleService(value).compactChat( + chat.id, + { kind: "desktop", ownerId: "renderer:1" }, + "operator", + ); + + assert.equal(result.compacted, true, JSON.stringify(result)); + assert.equal(providerObserved, true); + assert.doesNotMatch(JSON.stringify(providerPayloads), /HIDDEN_SKILL_INSTRUCTIONS/u); + assert.match(JSON.stringify(providerPayloads), /Visible operator request/u); + assert.doesNotMatch(JSON.stringify(await session.buildContext()), /HIDDEN_SKILL_INSTRUCTIONS/u); +}); + +test("disabling Skills cancels an operator compaction already at the provider", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-context-skill-cancel-")); + t.after(() => rm(root, { recursive: true, force: true })); + const providerId = "context-skill-cancel"; + const modelId = "context-skill-cancel-model"; + const { faux, runtime } = compactionRuntime(providerId, modelId); + let providerStarted!: () => void; + const atProvider = new Promise((resolve) => { + providerStarted = resolve; + }); + faux.setResponses([ + async (_context, options) => { + providerStarted(); + await new Promise((resolve) => { + if (options?.signal?.aborted) resolve(); + else + options?.signal?.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + return fauxAssistantMessage(validSummary("must not commit")); + }, + ]); + const chat: Chat = { + ...baseChat, + providerId, + model: modelId, + messages: [ + { + id: "cancel-user", + role: "user", + content: `Compact ${"x".repeat(90_000)}`, + createdAt: 10, + }, + { + id: "cancel-assistant", + role: "assistant", + content: "Current answer", + createdAt: 20, + }, + { + id: "cancel-user-two", + role: "user", + content: `Continue ${"y".repeat(10_000)}`, + createdAt: 30, + }, + { + id: "cancel-assistant-two", + role: "assistant", + content: "Latest answer", + createdAt: 40, + }, + ], + }; + const store = new PiCompactionSessionStore({ root: async () => root }); + const session = await store.openChat(chat.id); + const { value } = deps({ + getChat: async () => chat, + skillsEnabled: async () => true, + openSession: async () => session, + resolveRuntime: async () => runtime, + }); + const service = new ContextLifecycleService(value); + const operation = service.compactChat( + chat.id, + { kind: "desktop", ownerId: "renderer:1" }, + "operator", + ); + await atProvider; + service.cancelForSkillsDisabled(); + + assert.deepEqual(await operation, { compacted: false, reason: "cancelled" }); + assert.equal( + (await session.getBranch()).some((entry) => entry.type === "compaction"), + false, + ); +}); diff --git a/main/services/context-lifecycle-service.ts b/main/services/context-lifecycle-service.ts index 2e33fa2f..597e174b 100644 --- a/main/services/context-lifecycle-service.ts +++ b/main/services/context-lifecycle-service.ts @@ -4,7 +4,10 @@ import { randomUUID } from "node:crypto"; import { estimateTokens, type ThinkingLevel } from "@earendil-works/pi-agent-core"; import { selectCanonicalBotChat } from "./bot-canonical-chat.js"; import { createPiCompactionModels, PiCompactionCoordinator } from "./pi-compaction-core.js"; -import { syncChatMessagesToPiSession } from "./pi-compaction-session-store.js"; +import { + projectVisibleHistoryWithoutSkills, + syncChatMessagesToPiSession, +} from "./pi-compaction-session-store.js"; import type { ResolvedModelRuntime } from "./model-runtime-core.js"; import type { AssistantMessage } from "@earendil-works/pi-ai"; import type { Chat, ChatMeta } from "./types.js"; @@ -43,6 +46,7 @@ export interface ContextLifecycleServiceDeps { resolveLocalModel?(providerId: string, model: string): Promise; compactionEnabled?(): boolean; compactionEligible?(chat: Chat): boolean | Promise; + skillsEnabled?(): Promise; getChat(chatId: string): Promise; listChatsByBot(botId: string): Promise; isBotArchived(botId: string): Promise; @@ -84,6 +88,12 @@ export class ContextLifecycleService { return true; } + cancelForSkillsDisabled(): void { + for (const operation of this.activeCompactions.values()) { + operation.controller.abort(new DOMException("Compaction cancelled.", "AbortError")); + } + } + async compactChat( chatId: string, audience: ContextLifecycleAudience, @@ -159,13 +169,16 @@ export class ContextLifecycleService { return { compacted: false, reason: "context_metadata_invalid" }; } - const session = await this.deps.openSession(chat.id); + let session = await this.deps.openSession(chat.id); await syncChatMessagesToPiSession( session, chat.messages, model, model.input.includes("image"), ); + if ((await this.deps.skillsEnabled?.()) === false) { + session = await projectVisibleHistoryWithoutSkills(session, chat.messages, model); + } const coordinator = new PiCompactionCoordinator({ session, engine, @@ -181,6 +194,9 @@ export class ContextLifecycleService { signal: operationAbort.signal, }); const result = await coordinator.compact(); + if (operationAbort.signal.aborted) { + return { compacted: false, reason: "cancelled" }; + } if (result.errorMessage) { return { compacted: false, reason: "compaction_failed" }; } diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index d8f02e34..79200ad1 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -138,6 +138,7 @@ import { piCompactionSessionStore, recordPiEffectRecoveryBoundary, syncChatMessagesToPiSession, + projectVisibleHistoryWithoutSkills, type PiVisibleTurnLease, } from "./pi-compaction-session-store.js"; import { piRuntimeEffectStore } from "./pi-runtime-effect-store.js"; @@ -341,6 +342,8 @@ function piResourcesForSkillSnapshot( } export interface GenerationExecutionOptions { + /** Main-owned Telegram queue provenance, resolved freshly at generation. */ + telegramSkillInvocation?: import("./telegram/telegram-queue.js").TelegramSkillInvocation; /** Internal-only execution policy. Renderer chat starts always use the workspace permission. */ permission?: GenerationPermission; /** Scheduled and other background runs can withhold tools that would recurse or mutate. */ @@ -1494,6 +1497,17 @@ export const llmClient = { authoritativeMode, ); } + if (options.telegramSkillInvocation) { + const selection = options.telegramSkillInvocation; + const currentUser = [...authoritativeChat.messages].reverse().find((message) => message.role === "user"); + if (options.interactionSurface !== "telegram" || selection.workspaceId !== authoritativeWorkspaceId || !currentUser) { + throw new Error("The queued Telegram skill no longer matches this conversation."); + } + const skill = await skillRegistry.resolveFresh(selection.workspaceId, selection.invocationId); + const prepared = formatPreparedSkillInvocation(skill, currentUser.content, selection.workspaceId, currentUser.id); + initialization.skillInvocation = prepared; + initialization.skillPrompt = prepared.formattedPrompt; + } if (workspaceMutationGate.isChanging(authoritativeWorkspaceId)) { throw new Error("The workspace is changing. Try again in a moment."); } @@ -2013,7 +2027,7 @@ export const llmClient = { signal: initialization.controller.signal, onEvent: onCompactionEvent, }; - const promptJournal = piSession; + const currentUser = [...generationChat.messages] .reverse() @@ -2021,16 +2035,25 @@ export const llmClient = { const priorVisibleMessages = currentUser ? generationChat.messages.filter((message) => message.id !== currentUser.id) : generationChat.messages; + const skillsEnabledForTurn = (await configStore.getSettings()).skillsEnabled !== false; + if (!skillsEnabledForTurn) { + piSession = await projectVisibleHistoryWithoutSkills(piSession, priorVisibleMessages, model); + } + const promptJournal = piSession; const contentOverrides = new Map(); if (currentUser) { if ( initialization.skillInvocation?.userMessageId === currentUser.id && initialization.skillPrompt ) { + if ((await configStore.getSettings()).skillsEnabled === false) { + throw new Error("Skills are disabled in Settings → Skills."); + } if (preparedBotContext) { const allowedSkill = skillSnapshot?.available.find( (skill) => - skill.name === currentUser.skill?.name && skill.source === currentUser.skill.source, + skill.name === initialization.skillInvocation?.provenance.name && + skill.source === initialization.skillInvocation.provenance.source, ); if (!allowedSkill) { throw new Error("This skill is not enabled for this Bot chat."); @@ -3245,6 +3268,15 @@ export const llmClient = { return chatTurnAdmission.releaseMatching(chatId, turnId, ownerId); }, + /** Discard active snapshots when the user disables skills across the app. */ + cancelForSkillsDisabled(): void { + for (const streamId of new Set([...initializing.keys(), ...active.keys()])) { + this.cancel(streamId, "user_stop"); + } + // Detached children may retain skill instructions in their forked context. + subagentRuntimeRegistry.abortAll(); + }, + /** Closing the global gate cancels every snapshot that could race the setting change. */ cancelComputerUseGenerations(): void { computerUseGenerationGate.close(); diff --git a/main/services/pi-compaction-core.test.ts b/main/services/pi-compaction-core.test.ts index 52c7a268..19916785 100644 --- a/main/services/pi-compaction-core.test.ts +++ b/main/services/pi-compaction-core.test.ts @@ -22,6 +22,7 @@ import { beginPiVisibleTurnLease, PiCompactionSessionStore, syncChatMessagesToPiSession, + projectVisibleHistoryWithoutSkills, } from "./pi-compaction-session-store.js"; import type { ChatMessage } from "./types.js"; import { createPiSessionPort, type PiSessionPort } from "./pi-session-port.js"; @@ -1610,3 +1611,76 @@ test("startup reconciliation removes indexed orphan journals", async (t) => { await store.reconcileChats(new Set()); await assert.rejects(stat(metadata.path), { code: "ENOENT" }); }); + + +test("disabled skills project visible history without old expanded inputs, results, or compactions", async () => { + const { model } = compactionFixture(); + const session = await memorySession(); + const visible: ChatMessage[] = [ + { id: "skill-user", role: "user", content: "Review this code", createdAt: 10 }, + { id: "skill-answer", role: "assistant", content: "The code looks sound", createdAt: 20 }, + ]; + await syncChatMessagesToPiSession(session, visible, model, false, + new Map([["skill-user", "HIDDEN_SKILL_INSTRUCTIONS Review this code"]])); + await session.appendMessage({ ...assistant(model), content: [{ type: "toolCall", id: "skill-call", name: "skill_review", arguments: {} }] }); + await session.appendMessage({ role: "toolResult", toolCallId: "skill-call", toolName: "skill_review", + content: [{ type: "text", text: "HIDDEN_SKILL_RESULT" }], isError: false, timestamp: 30 }); + await session.appendCompaction({ id: "old-compaction", summary: "HIDDEN_SKILL_SUMMARY", retainedTail: [], tokensBefore: 100 }); + const durableBefore = JSON.stringify(await session.getEntries()); + const projected = await projectVisibleHistoryWithoutSkills(session, visible, model); + for (const view of [await projected.buildContext(), await projected.getBranch(), await projected.getEntries()]) { + assert.doesNotMatch(JSON.stringify(view), /HIDDEN_SKILL/u); + assert.match(JSON.stringify(view), /Review this code/u); + assert.match(JSON.stringify(view), /The code looks sound/u); + } + assert.equal(JSON.stringify(await session.getEntries()), durableBefore, "visible projection never deletes or rewrites durable history"); + const call = { ...assistant(model), content: [{ type: "toolCall" as const, id: "normal-call", name: "read_file", arguments: {} }] }; + await projected.appendMessage(call); + await projected.appendMessage({ role: "toolResult", toolCallId: "normal-call", toolName: "read_file", + content: [{ type: "text", text: "CURRENT_TOOL_RESULT" }], isError: false, timestamp: 40 }); + const context = JSON.stringify(await projected.buildContext()); + assert.doesNotMatch(context, /HIDDEN_SKILL/u); + assert.match(context, /normal-call/u); + assert.match(context, /CURRENT_TOOL_RESULT/u); + await projected.appendCompaction({ id: "new-compaction", summary: "CLEAN_CURRENT_SUMMARY", retainedTail: [], tokensBefore: 100 }); + assert.match(JSON.stringify(await projected.buildContext()), /CLEAN_CURRENT_SUMMARY/u); + assert.doesNotMatch(JSON.stringify(await projected.buildContext()), /HIDDEN_SKILL/u); + assert.match(JSON.stringify(await session.getEntries()), /HIDDEN_SKILL_INSTRUCTIONS/u); +}); + + +test("skill-free visible context compacts and reopens through the real JSONL repository", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "aiden-skill-free-jsonl-")); + t.after(() => rm(root, { recursive: true, force: true })); + const { model, models, faux } = compactionFixture(); + let compactedVisibleContext = false; + faux.setResponses([(context) => { + const text = JSON.stringify(context); + assert.doesNotMatch(text, /HIDDEN_SKILL/u); + assert.match(text, /Visible user request/u); + compactedVisibleContext = true; + return fauxAssistantMessage(structuredSummary("CLEAN_SKILL_FREE_CHECKPOINT")); + }]); + const store = new PiCompactionSessionStore({ root: async () => root }); + const session = await store.openChat("skill-free-persistent"); + const messages: ChatMessage[] = [ + { id: "visible-one", role: "user", content: `Visible user request ${"x".repeat(2000)}`, createdAt: 10 }, + { id: "visible-two", role: "assistant", content: `Visible response ${"y".repeat(1000)}`, createdAt: 20 }, + { id: "visible-three", role: "user", content: "Continue with the next part", createdAt: 30 }, + { id: "visible-four", role: "assistant", content: "Current answer", createdAt: 40 }, + ]; + await syncChatMessagesToPiSession(session, messages, model, false, + new Map([["visible-one", "HIDDEN_SKILL_EXPANSION"]])); + const projected = await projectVisibleHistoryWithoutSkills(session, messages, model); + const coordinator = new PiCompactionCoordinator({ session: projected, model, models, thinkingLevel: "off", + settings: { enabled: true, reserveTokens: 100, keepRecentTokens: 100 } }); + const result = await coordinator.compact(); + assert.equal(result.compacted, true, result.errorMessage); + assert.equal(compactedVisibleContext, true); + const expected = (await projected.buildContext()).messages; + const reopened = await new PiCompactionSessionStore({ root: async () => root }).openChat("skill-free-persistent"); + assert.deepEqual((await reopened.buildContext()).messages, expected); + assert.match(JSON.stringify(await reopened.getEntries()), /HIDDEN_SKILL_EXPANSION/u, + "rich history is still durable when the user re-enables skills"); + assert.doesNotMatch(JSON.stringify(await reopened.buildContext()), /HIDDEN_SKILL/u); +}); diff --git a/main/services/pi-compaction-session-store.ts b/main/services/pi-compaction-session-store.ts index ec15741d..587c167a 100644 --- a/main/services/pi-compaction-session-store.ts +++ b/main/services/pi-compaction-session-store.ts @@ -3,12 +3,13 @@ import { chmod, open, readFile, readdir, rename, stat, unlink, writeFile } from import path from "node:path"; import { type AgentMessage, + buildSessionContext, } from "@earendil-works/pi-agent-core"; import { cleanupSessionResources, type Api, type Model } from "@earendil-works/pi-ai"; import { ensureUserDataDir } from "./data-store.js"; import { isDevelopmentRuntime } from "../runtime-mode-core.js"; import { chatMessageToPiMessage } from "./generation-messages.js"; -import type { PiPersistentSessionMetadata, PiSessionPort } from "./pi-session-port.js"; +import type { PiPersistentSessionMetadata, PiSessionMetadata, PiSessionPort, PiSessionEntry, PiEntryProjector } from "./pi-session-port.js"; import { createCurrentPiSessionRepository, type PiSessionRepositoryPort, @@ -272,6 +273,59 @@ export async function syncChatMessagesToPiSession( } } +/** + * Execute from visible history while skills are disabled. Old journal messages + * may contain expanded skill inputs, tool results, or summaries of those values. + * They stay durable but must not enter inference, compaction, or history recall. + * New messages still append to the real journal, including normal tool pairs. + */ +export async function projectVisibleHistoryWithoutSkills( + session: PiSessionPort, + messages: readonly ChatMessage[], + model: Model, +): Promise> { + const originalEntries = await session.getEntries(); + const originalLeafId = await session.getLeafId(); + const cutoff = originalEntries.reduce((latest, entry) => Math.max(latest, entry.seq), -1); + const visible: PiSessionEntry[] = messages.flatMap((message, index) => { + const id = `visible-skill-free-${message.id}`; + const parentId = index === 0 ? null : `visible-skill-free-marker-${messages[index - 1]!.id}`; + return [ + { type: "message" as const, id, parentId, seq: index * 2 - messages.length * 2, + timestamp: message.createdAt, message: chatMessageToPiMessage(message, model, true) }, + { type: "custom" as const, id: `visible-skill-free-marker-${message.id}`, parentId: id, + seq: index * 2 + 1 - messages.length * 2, timestamp: message.createdAt, + customType: AIDEN_CHAT_MESSAGE_MARKER, data: { chatMessageId: message.id } }, + ]; + }); + // Compaction fences writes against the real durable leaf. Keep that identity + // as an inert boundary after the synthetic prefix, without exposing its data. + if (originalLeafId) visible.push({ + type: "custom", id: originalLeafId, parentId: visible[visible.length - 1]?.id ?? null, + seq: cutoff, timestamp: 0, customType: "aiden.skills-hidden-context-boundary.v1", + }); + const view = (projectors: Readonly> = {}): PiSessionPort => { + const getBranch = async () => [ + ...structuredClone(visible), + ...(await session.getBranch()).filter((entry) => entry.seq > cutoff), + ]; + return { + appendMessage: (message) => session.appendMessage(message), + appendCustomEntry: (type, data) => session.appendCustomEntry(type, data), + appendCompaction: (input) => session.appendCompaction(input), + getBranch, + // Recall must not bypass the projection by traversing historical branches. + getEntries: getBranch, + buildContext: async () => buildSessionContext(await getBranch(), { entryProjectors: projectors }), + getLeafId: () => session.getLeafId(), + getMetadata: () => session.getMetadata(), + moveTo: (entryId) => session.moveTo(entryId), + withEntryProjectors: (additional) => view({ ...projectors, ...additional }), + }; + }; + return view(); +} + async function appendPiTransaction( session: PiSessionPort, operation: () => Promise, diff --git a/main/services/portable-config-core.ts b/main/services/portable-config-core.ts index 7a46aeb5..25ea9247 100644 --- a/main/services/portable-config-core.ts +++ b/main/services/portable-config-core.ts @@ -565,6 +565,7 @@ function normalizeSettingsShape(value: unknown): SettingsShape { "dictationSounds", "showLocalModelReasoning", "memoryEnabled", + "skillsEnabled", "computerUseEnabled", "scheduledTasksEnabled", "scheduledDefaultMcpEnabled", diff --git a/main/services/skill-registry-main.ts b/main/services/skill-registry-main.ts index 262e85e7..f7ac1633 100644 --- a/main/services/skill-registry-main.ts +++ b/main/services/skill-registry-main.ts @@ -4,6 +4,7 @@ import { SkillRegistry } from "./skill-registry.js"; /** Process-owned registry. Its invocation key is generated once and never leaves main. */ export const skillRegistry = new SkillRegistry({ + isEnabled: async () => (await configStore.getSettings()).skillsEnabled !== false, getWorkspace: (id) => configStore.getWorkspace(id), listConfigured: () => configStore.listSkills(), discover: (workspaceRoot) => discoverSkillCandidates(workspaceRoot), diff --git a/main/services/skill-registry.test.ts b/main/services/skill-registry.test.ts index 47302b1b..2f559d8a 100644 --- a/main/services/skill-registry.test.ts +++ b/main/services/skill-registry.test.ts @@ -356,3 +356,46 @@ test("tool, prompt, catalog, and explicit resolution share one collision-heavy s await assert.rejects(h.registry.resolve("one", entry.invocationId), SkillInvocationError); } }); + +test("global disable hides cached skills and rejects stale invocations without reading skill files", async () => { + let enabled = true; + let configuredReads = 0; + const h = harness({ + isEnabled: async () => enabled, + listConfigured: async () => { + configuredReads += 1; + return [configured()]; + }, + }); + h.setDiscovered([discovered("global"), discovered("workspace")]); + const before = await h.registry.snapshot("one"); + const selected = before.available[0]!; + assert(before.catalog.length > 0); + const reads = h.roots.length; + enabled = false; + const disabled = await h.registry.snapshot("one"); + assert.deepEqual(disabled.available, []); + assert.deepEqual(disabled.skills, []); + assert.deepEqual(disabled.catalog, []); + assert.equal(formatAvailableSkills(disabled), undefined); + assert.deepEqual(buildSkillTools(disabled), []); + await assert.rejects(h.registry.resolveFresh("one", selected.invocationId), SkillInvocationError); + assert.equal(configuredReads, 1); + assert.equal(h.roots.length, reads); + enabled = true; + assert((await h.registry.snapshot("one")).available.length > 0); + assert.equal(configuredReads, 2, "re-enabling refreshes rather than reviving a stale cache"); +}); + +test("a skill scan completing after global disable cannot publish instructions", async () => { + let enabled = true; + const h = harness({ + isEnabled: async () => enabled, + discover: async () => { + enabled = false; + return [discovered("global")]; + }, + }); + h.setConfigured([configured()]); + assert.deepEqual((await h.registry.snapshot("one")).skills, []); +}); diff --git a/main/services/skill-registry.ts b/main/services/skill-registry.ts index 5c6407b3..8be04b24 100644 --- a/main/services/skill-registry.ts +++ b/main/services/skill-registry.ts @@ -36,6 +36,7 @@ export interface SkillRegistrySnapshot { export interface SkillRegistryDependencies { getWorkspace(id: string): Promise; + isEnabled(): Promise; listConfigured(): Promise; discover(workspaceRoot?: string): Promise; now(): number; @@ -120,6 +121,7 @@ export class SkillRegistry { constructor(dependencies: SkillRegistryOptions) { this.#dependencies = { + isEnabled: async () => true, now: () => Date.now(), invocationKey: randomBytes(32), cacheTtlMs: DEFAULT_CACHE_TTL_MS, @@ -152,6 +154,11 @@ export class SkillRegistry { if (!workspace.id || workspace.id.length > 256) { throw new SkillInvocationError("workspace_changed", "Invalid skill workspace."); } + // The global gate precedes cache reuse and all discovery/file reads. + if (!(await this.#dependencies.isEnabled())) { + this.invalidate(workspace.id); + return this.#project(workspace, []); + } const now = this.#dependencies.now(); const cached = this.#cache.get(workspace.id); if ( @@ -228,6 +235,7 @@ export class SkillRegistry { async #load( workspace: Pick, ): Promise { + if (!(await this.#dependencies.isEnabled())) return this.#project(workspace, []); const [configured, discovered] = await Promise.all([ this.#dependencies.listConfigured(), // No Access is also a discovery boundary: do not read workspace skill @@ -236,10 +244,19 @@ export class SkillRegistry { workspace.permission === "none" ? undefined : workspace.folderPath, ), ]); + // A disable may race an in-flight disk scan. Never publish that snapshot. + if (!(await this.#dependencies.isEnabled())) return this.#project(workspace, []); const resolved = resolveSkillCandidates([ ...configured.map(configuredCandidate), ...discovered.map((skill) => discoveredCandidate(skill, workspace.permission)), ]); + return this.#project(workspace, resolved); + } + + #project( + workspace: Pick, + resolved: readonly ResolvedSkillCandidate[], + ): SkillRegistrySnapshot { const fingerprint = skillRegistryFingerprint(workspace, resolved); const revision = `rf_${fingerprint}`; const projectionContext = { diff --git a/main/services/skill-tools.test.ts b/main/services/skill-tools.test.ts index c3a3cbd2..b0789a43 100644 --- a/main/services/skill-tools.test.ts +++ b/main/services/skill-tools.test.ts @@ -37,3 +37,24 @@ test("skill tool execution does not traverse a directory replaced after discover assert.match(text, /snapshotted instructions/u); assert.doesNotMatch(text, /CANARY_SECRET_NAME/u); }); + +test("a skill tool created before global disable refuses to return its instructions", async () => { + let enabled = true; + const tool = makeSkillTool( + { + stableId: "configured:one", + name: "Review", + description: "Review code", + instructions: "PRIVATE_SKILL_INSTRUCTIONS", + source: "configured", + enabled: true, + available: true, + invocationId: `sk1_${"a".repeat(43)}`, + toolKey: "skill_review", + }, + async () => enabled, + ); + assert.match(JSON.stringify(await tool.execute("first", {})), /PRIVATE_SKILL_INSTRUCTIONS/u); + enabled = false; + await assert.rejects(tool.execute("second", {}), /Skills are disabled/u); +}); diff --git a/main/services/skill-tools.ts b/main/services/skill-tools.ts index b375a090..afec4cbc 100644 --- a/main/services/skill-tools.ts +++ b/main/services/skill-tools.ts @@ -9,35 +9,43 @@ function textResult(text: string): AgentToolResult { return { content: [{ type: "text", text }], details: null }; } -export function makeSkillTool(skill: RegisteredSkill): AgentTool { +export function makeSkillTool( + skill: RegisteredSkill, + isEnabled: () => Promise = async () => true, +): AgentTool { const summary = skill.description ? `${skill.name}: ${skill.description}` : skill.name; - return declarePiRuntimeReplay({ - name: skillToolKey(skill), - label: skill.name, - description: `${summary} — call this to load detailed instructions before performing the task.`, - parameters: Type.Object({}), - execute: async (): Promise> => { - if (!skill.path) return textResult(skill.instructions); - const base = path.dirname(skill.path); - return textResult( - [ - ``, - skill.instructions, - "", - `Base directory for this skill: ${base}`, - "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", - "", - ].join("\n"), - ); + return declarePiRuntimeReplay( + { + name: skillToolKey(skill), + label: skill.name, + description: `${summary} — call this to load detailed instructions before performing the task.`, + parameters: Type.Object({}), + execute: async (): Promise> => { + if (!(await isEnabled())) throw new Error("Skills are disabled in Settings → Skills."); + if (!skill.path) return textResult(skill.instructions); + const base = path.dirname(skill.path); + return textResult( + [ + ``, + skill.instructions, + "", + `Base directory for this skill: ${base}`, + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "", + ].join("\n"), + ); + }, }, - }, "safe"); + "safe", + ); } export function buildSkillTools( snapshot: SkillRegistrySnapshot, allowWorkspaceSkills = true, + isEnabled: () => Promise = async () => true, ): AgentTool[] { return snapshot.available .filter((skill) => allowWorkspaceSkills || skill.source !== "workspace") - .map(makeSkillTool); + .map((skill) => makeSkillTool(skill, isEnabled)); } diff --git a/main/services/telegram/telegram-bot-api.test.ts b/main/services/telegram/telegram-bot-api.test.ts index bcdb6269..22b91beb 100644 --- a/main/services/telegram/telegram-bot-api.test.ts +++ b/main/services/telegram/telegram-bot-api.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { TelegramApiError, + createFetchTransport, TelegramBotApi, type TelegramApiResponse, type TelegramTransport, @@ -172,3 +173,21 @@ test("getUpdates with an already-aborted signal rejects immediately without call await assert.rejects(() => api.getUpdates(undefined, 0, AbortSignal.abort()), /aborted/i); assert.equal(called, false, "transport must not be invoked for an aborted signal"); }); + + +test("command menu updates have a bounded request without altering long-poll transport", async (t) => { + const signals: Array = []; + const timeouts: number[] = []; + const signal = new AbortController().signal; + t.mock.method(AbortSignal, "timeout", (milliseconds: number) => { timeouts.push(milliseconds); return signal; }); + t.mock.method(globalThis, "fetch", async (_url: unknown, options?: RequestInit) => { + signals.push(options?.signal); + return { json: async () => ({ ok: true, result: true }) }; + }); + const transport = createFetchTransport(async () => "test-token"); + await transport("setMyCommands", { commands: [] }); + await transport("getUpdates", { timeout: 25 }); + assert.deepEqual(timeouts, [10_000]); + assert.equal(signals[0], signal); + assert.equal(signals[1], undefined); +}); diff --git a/main/services/telegram/telegram-bot-api.ts b/main/services/telegram/telegram-bot-api.ts index c448eaf9..aa4165e5 100644 --- a/main/services/telegram/telegram-bot-api.ts +++ b/main/services/telegram/telegram-bot-api.ts @@ -190,6 +190,8 @@ export function createFetchTransport(tokenResolver: () => Promise method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + // A menu refresh must not indefinitely block later gate changes. + ...(method === "setMyCommands" ? { signal: AbortSignal.timeout(10_000) } : {}), }); return (await response.json()) as TelegramApiResponse; }; diff --git a/main/services/telegram/telegram-queue.ts b/main/services/telegram/telegram-queue.ts index 8e2214f2..e992a213 100644 --- a/main/services/telegram/telegram-queue.ts +++ b/main/services/telegram/telegram-queue.ts @@ -39,11 +39,18 @@ export interface TelegramBotBinding { /** Alias that makes the snapshot semantics explicit at call sites. */ export type TelegramBotBindingSnapshot = TelegramBotBinding; +export interface TelegramSkillInvocation { + readonly workspaceId: string; + readonly invocationId: string; +} + export interface QueuedTelegramTurn { /** Process-local opaque id used by Telegram queue controls. */ readonly id?: number; readonly lane: QueueLane; readonly text: string; + /** Opaque skill selection; instructions are resolved only at dispatch/generation. */ + readonly skillInvocation?: TelegramSkillInvocation; readonly attachments?: readonly Attachment[]; /** Telegram chat ID — used for API calls (sendMessage, sendChatAction). */ readonly chatId: number; diff --git a/main/services/telegram/telegram-service-core.test.ts b/main/services/telegram/telegram-service-core.test.ts index 50221c0b..667e0b41 100644 --- a/main/services/telegram/telegram-service-core.test.ts +++ b/main/services/telegram/telegram-service-core.test.ts @@ -97,6 +97,7 @@ function createMockApi(opts: MockApiOptions) { const richMessages: Array<{ chatId: number; threadId?: number; markdown: string }> = []; const voiceMessages: Array<{ chatId: number; threadId?: number; bytes: Uint8Array }> = []; const calls: string[] = []; + const commandRegistrations: Array = []; let getMeCalls = 0; let getUpdatesCalls = 0; let sendChatActionCalls = 0; @@ -104,6 +105,8 @@ function createMockApi(opts: MockApiOptions) { const api = { sentMessages, + commandRegistrations, + async setMyCommands(commands: readonly { command: string; description: string }[]) { commandRegistrations.push(commands); }, richMessages, voiceMessages, calls, @@ -481,6 +484,8 @@ function createLogs() { // --------------------------------------------------------------------------- interface HarnessOptions { + listPromptCommands?: import("./telegram-service-core.js").TelegramServiceDeps["listPromptCommands"]; + validateSkillInvocation?: import("./telegram-service-core.js").TelegramServiceDeps["validateSkillInvocation"]; enabled?: boolean; hasToken?: boolean; allowedUserId?: number; @@ -570,6 +575,8 @@ function harness(o: HarnessOptions = {}) { assertBotBindingStoreHealthy: o.assertBotBindingStoreHealthy, listWorkspaces: async () => o.workspaces ?? [], listModels: o.listModels, + listPromptCommands: o.listPromptCommands, + validateSkillInvocation: o.validateSkillInvocation, applyModelSelection: o.applyModelSelection, compactChat: o.compactChat, abortChat: o.abortChat, @@ -1756,3 +1763,34 @@ test("manual Telegram compaction preserves the shared busy admission result", as assert.equal(result.turnMock.startCalls(), 0); result.service.stop(); }); + + +test("a skill queued before global disable is rejected at dispatch and command registration refreshes", async () => { + let skillsEnabled = true; + let validations = 0; + const h = harness({ + enabled: true, hasToken: true, allowedUserId: 42, pendingTurn: true, autoStop: false, + telegramWorkspaceId: "project", + workspaces: [{ id: "project", name: "Project", folderPath: "/tmp/project" }], + batches: [[makeUpdate(1, makeMessage(10, person(42), "ordinary work")), + makeUpdate(2, makeMessage(11, person(42), "/review inspect the patch"))]], + listPromptCommands: async () => skillsEnabled ? [{ command: "review", description: "Review code", + skillInvocation: { workspaceId: "project", invocationId: "opaque-skill" } }] : [], + validateSkillInvocation: async () => { validations += 1; if (!skillsEnabled) throw new Error("Skills are disabled"); }, + }); + await h.service.start(); + await waitFor(() => h.turnMock.startCalls() === 1 && h.service.queueSize === 1); + assert(h.api.commandRegistrations[h.api.commandRegistrations.length - 1]?.some(({ command }) => command === "review")); + skillsEnabled = false; + await h.service.refreshCommands(); + assert(!h.api.commandRegistrations[h.api.commandRegistrations.length - 1]?.some(({ command }) => command === "review")); + h.turnMock.completePendingTurn(); + await waitFor(() => h.api.sentMessages.some(({ text }) => text.includes("Skills are disabled"))); + assert.equal(validations, 1); + assert.equal(h.turnMock.startCalls(), 1, "disabled queued skill never starts inference"); + assert.equal(h.turnMock.appendCalls(), 1, "expanded or disabled skill text never enters visible history"); + skillsEnabled = true; + await h.service.refreshCommands(); + assert(h.api.commandRegistrations[h.api.commandRegistrations.length - 1]?.some(({ command }) => command === "review")); + h.service.stop(); +}); diff --git a/main/services/telegram/telegram-service-core.ts b/main/services/telegram/telegram-service-core.ts index 328e0013..12f060ca 100644 --- a/main/services/telegram/telegram-service-core.ts +++ b/main/services/telegram/telegram-service-core.ts @@ -113,6 +113,7 @@ export interface TelegramServiceDeps { botId?: string; }): Promise; listPromptCommands?(workspaceId?: string): Promise; + validateSkillInvocation?(selection: NonNullable): Promise; readOutboundAttachment?(workspaceId: string | undefined, requestedPath: string): Promise<{ bytes: Uint8Array; name: string; @@ -173,6 +174,7 @@ interface TelegramPromptCommand { command: string; description: string; expand?(argument: string): string; + skillInvocation?: QueuedTelegramTurn["skillInvocation"]; handle?(argument: string, message: TelegramMessage, context: TelegramExtensionRuntimeContext): Promise; } @@ -397,6 +399,25 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { deps.info("Telegram bridge disconnected."); } + let commandRefresh: Promise = Promise.resolve(); + function refreshCommands(): Promise { + // Serialize mutations so an older registration cannot restore disabled skills. + commandRefresh = commandRefresh.then(async () => { + if (!started) return; + const settings = await deps.config.getSettings(); + const templates = await deps.listPromptCommands?.(settings.telegramWorkspaceId) ?? []; + if (!started) return; + await deps.api.setMyCommands?.([ + ...TELEGRAM_COMMANDS, + ...templates.slice(0, Math.max(0, 100 - TELEGRAM_COMMANDS.length)) + .map(({ command, description }) => ({ command, description })), + ]); + }).catch((cause) => { + deps.warn(`Telegram command registration failed: ${cause instanceof Error ? cause.message : String(cause)}`); + }); + return commandRefresh; + } + async function runPollLoop(signal: AbortSignal): Promise { try { const me = await deps.api.getMe(); @@ -411,13 +432,7 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { deps.warn(`Telegram thread provisioning is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`); }); } - const templates = await deps.listPromptCommands?.(settings.telegramWorkspaceId).catch(() => []) ?? []; - await deps.api.setMyCommands?.([ - ...TELEGRAM_COMMANDS, - ...templates.slice(0, Math.max(0, 100 - TELEGRAM_COMMANDS.length)).map(({ command, description }) => ({ command, description })), - ]).catch((cause) => { - deps.warn(`Telegram command registration failed: ${cause instanceof Error ? cause.message : String(cause)}`); - }); + await refreshCommands(); } catch (cause) { lastError = cause instanceof Error ? cause.message : String(cause); deps.error("Telegram getMe failed.", cause); @@ -1385,10 +1400,13 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { if (reply) await deps.api.sendMessage({ chatId, threadId: message.message_thread_id, text: reply }); return; } - if (!template.expand) throw new Error(`Telegram command /${template.command} has no handler.`); + if (!template.expand && !template.skillInvocation) throw new Error(`Telegram command /${template.command} has no handler.`); await enqueuePrompt({ lane: "default", - text: template.expand(commandArgument(command)), + text: template.skillInvocation + ? commandArgument(command) || `Use the ${template.command} skill.` + : template.expand!(commandArgument(command)), + ...(template.skillInvocation ? { skillInvocation: template.skillInvocation } : {}), chatId, threadId: message.message_thread_id, ownerUserId: message.from?.id ?? chatId, @@ -1441,6 +1459,12 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { ? { kind: "project" as const, workspaceId: turn.binding.backingWorkspaceId } : await deps.turn.resolveWorkspace(turn.workspaceId); const workspaceId = workspace.kind === "project" ? workspace.workspaceId : undefined; + if (turn.skillInvocation) { + if (turn.skillInvocation.workspaceId !== workspaceId || !deps.validateSkillInvocation) { + throw new Error("This queued skill is no longer available for the Telegram workspace."); + } + await deps.validateSkillInvocation(turn.skillInvocation); + } const chatId = turn.binding?.backingChatId ?? telegramChatId( turn.ownerUserId, workspaceId, @@ -1498,7 +1522,7 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { workspace, turn.attachments, activity.observe, - { binding: turn.binding }, + { binding: turn.binding, skillInvocation: turn.skillInvocation }, ); await activity.settle(); await deliverReply( @@ -1752,6 +1776,7 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { connect, disconnect, resetPairing, + refreshCommands, ensureThreads, clearThreads, getStatus, diff --git a/main/services/telegram/telegram-service.ts b/main/services/telegram/telegram-service.ts index 86ed7688..765b7439 100644 --- a/main/services/telegram/telegram-service.ts +++ b/main/services/telegram/telegram-service.ts @@ -27,7 +27,6 @@ import { contextLifecycleService } from "../context-lifecycle-service-main.js"; import { createTelegramLifecycleAdapter } from "../context-lifecycle-adapters.js"; import { transcribe } from "../transcription.js"; import { skillRegistry } from "../skill-registry-main.js"; -import { formatSkillInvocation } from "@earendil-works/pi-agent-core"; import { mkdir, readFile, @@ -650,21 +649,15 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { description: (skill.description || `Run ${skill.name}`) .replace(/\s+/gu, " ") .slice(0, 256), - expand: (argument: string) => - formatSkillInvocation( - { - name: skill.name, - description: skill.description, - content: skill.instructions, - filePath: skill.path ?? "/Aiden/Configured Skills/SKILL.md", - }, - argument, - ), + skillInvocation: { workspaceId, invocationId: skill.invocationId }, }, ]; }); return [...extensionCommands, ...skillCommands]; }, + validateSkillInvocation: async ({ workspaceId, invocationId }) => { + await skillRegistry.resolveFresh(workspaceId, invocationId); + }, readOutboundAttachment: readWorkspaceAttachment, applyModelSelection: async (choice) => { await setProfileSettings(profile, { @@ -867,6 +860,9 @@ export function createTelegramProfileManager() { const profiles = await refreshProfiles(); await Promise.all(profiles.map((profile) => serviceFor(profile).start())); }, + async refreshCommands(): Promise { + await Promise.all([...services.values()].map((service) => service.refreshCommands())); + }, stop(): void { for (const service of services.values()) service.stop(); }, diff --git a/main/services/telegram/telegram-turn.test.ts b/main/services/telegram/telegram-turn.test.ts index 956397f8..88e2a0c8 100644 --- a/main/services/telegram/telegram-turn.test.ts +++ b/main/services/telegram/telegram-turn.test.ts @@ -563,3 +563,19 @@ test("owner.send throws after destroy is called", () => { assert.equal(bg.owner.isDestroyed(), true); assert.throws(() => bg.owner.send("chat:done", { content: "late" }), /no longer active/); }); + + +test("Telegram skill provenance reaches generation while only raw user text is persisted", async () => { + const chat = mockChatStore(); + const selection = { workspaceId: "project", invocationId: "opaque-skill" }; + const { deps } = mockDeps({ store: chat.store, llm: mockLlm(async (streamId, params, owner, options) => { + assert.deepEqual(options.telegramSkillInvocation, selection); + assert.equal(params.messages[0]?.content, "inspect this patch"); + owner.send("chat:done", { streamId, content: "Reviewed" }); + return true; + }) }); + const result = await sendTelegramTurn(deps, "telegram-test", "inspect this patch", + { kind: "project", workspaceId: "project" }, undefined, undefined, { skillInvocation: selection }); + assert.equal(result.ok, true); + assert.deepEqual(chat.appended.map(({ content }) => content), ["inspect this patch"]); +}); diff --git a/main/services/telegram/telegram-turn.ts b/main/services/telegram/telegram-turn.ts index fd35c2db..0d1ce52a 100644 --- a/main/services/telegram/telegram-turn.ts +++ b/main/services/telegram/telegram-turn.ts @@ -11,7 +11,7 @@ import type { GenerationThinkingLevel } from "../../../renderer/shared/generatio import type { UsageRequestSource } from "../usage-store-core.js"; import type { ChatGenerationOwner } from "../chat-generation-owner.js"; import { scheduledProviderFingerprint } from "../schedule-provider-binding.js"; -import type { TelegramBotBindingSnapshot } from "./telegram-queue.js"; +import type { TelegramBotBindingSnapshot, TelegramSkillInvocation } from "./telegram-queue.js"; import { telegramBotNoticeAudienceId } from "./telegram-profile-config.js"; /** Minimal llmClient surface the shim needs. */ @@ -39,6 +39,7 @@ export interface TelegramLlmClient { turnId: string; botAudienceId?: string; providerFingerprint?: string; + telegramSkillInvocation?: TelegramSkillInvocation; }, ): Promise; isChatBusy(chatId: string): boolean; @@ -240,7 +241,7 @@ export async function sendTelegramTurn( workspace?: TelegramWorkspaceResolution, attachments?: readonly Attachment[], observer?: (channel: NotificationChannel, payload: unknown) => void, - options?: { binding?: TelegramBotBindingSnapshot }, + options?: { binding?: TelegramBotBindingSnapshot; skillInvocation?: TelegramSkillInvocation }, ): Promise { const resolvedWorkspace = workspace ?? (await deps.resolveWorkspace()); if (resolvedWorkspace.kind === "stale") { @@ -354,6 +355,7 @@ export async function sendTelegramTurn( interactionSurface: "telegram", usageSource: "telegram", turnId: streamId, + ...(options?.skillInvocation ? { telegramSkillInvocation: options.skillInvocation } : {}), ...(options?.binding ? { botAudienceId: telegramBotNoticeAudienceId( diff --git a/main/services/tools.ts b/main/services/tools.ts index 6700a7a9..4a1b2cff 100644 --- a/main/services/tools.ts +++ b/main/services/tools.ts @@ -179,11 +179,14 @@ export async function buildAgentTools(ctx: ToolContext): Promise { if (webSearchTool) tools.push(webSearchTool); // Every skill consumer uses this exact authoritative snapshot. + const skillsEnabled = async () => (await configStore.getSettings()).skillsEnabled !== false; const skillSnapshot = - ctx.skillSnapshot ?? - (ctx.workspaceId ? await skillRegistry.snapshot(ctx.workspaceId) : undefined); + ctx.includeSkillTools !== false && (await skillsEnabled()) + ? (ctx.skillSnapshot ?? + (ctx.workspaceId ? await skillRegistry.snapshot(ctx.workspaceId) : undefined)) + : undefined; if (ctx.includeSkillTools !== false && skillSnapshot) { - tools.push(...buildSkillTools(skillSnapshot, ctx.permission !== "none")); + tools.push(...buildSkillTools(skillSnapshot, ctx.permission !== "none", skillsEnabled)); } // MCP server tools. diff --git a/main/services/types.ts b/main/services/types.ts index 172af243..ce2ec9ad 100644 --- a/main/services/types.ts +++ b/main/services/types.ts @@ -567,6 +567,8 @@ export interface AppSettings { providerThinkingByModel?: Record>; /** Presentation-only Pi thinking visibility for models running on a local deployment. */ showLocalModelReasoning?: boolean; + /** Global skill discovery/invocation gate. Omitted means enabled. */ + skillsEnabled?: boolean; /** Global durable-memory gate. Omitted means enabled. */ memoryEnabled?: boolean; /** Global opt-in for the external cua-driver Computer Use beta. */ diff --git a/package.json b/package.json index 91758a29..2a5732d9 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "test:model-insights": "tsx --test main/services/openrouter-benchmark.test.ts main/services/models.test.ts main/services/provider-model-info-core.test.ts main/handlers/ipc-contract.test.ts", "test:model-pad": "tsx --test renderer/components/settings/model-pad-settings.test.tsx renderer/lib/google-provider-migration.test.ts renderer/lib/model-pad-layout.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/pi-provider-display.test.ts", "test:command-system": "tsx --test main/services/native-menu-command-contract.test.ts main/services/renderer-readiness-core.test.ts main/services/shortcut-registration-core.test.ts main/services/shortcut-transaction-core.test.ts main/services/superseding-task-core.test.ts renderer/lib/appearance-intent.test.ts renderer/lib/command-palette-contract.test.ts renderer/lib/command-palette-recent.test.ts renderer/lib/command-system-core.test.ts renderer/lib/shortcut-settings-contract.test.ts renderer/lib/use-model-selection.test.ts renderer/shared/keybindings.test.ts", - "test:slash-commands": "tsx --test main/services/generation-initialization-terminal.test.ts main/handlers/attachments.contract.test.ts main/handlers/chat.parse.test.ts main/handlers/chat-create-params.test.ts main/handlers/chat-session-params.test.ts main/handlers/worktree-create-params.test.ts main/services/chat-workspace-authority.test.ts main/handlers/chats.append.contract.test.ts main/services/attachment-contract.test.ts main/services/attachments.test.ts main/services/chat-append-commit.test.ts main/services/chat-export.test.ts main/services/chat-message-contract.test.ts main/services/chat-session-copy.test.ts main/services/chat-store-core.test.ts main/services/chat-turn-admission.test.ts main/services/generation-messages.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/scheduled-chat-creation.test.ts main/services/skill-invocation-flow.integration.test.ts main/services/skill-invocation-turn.test.ts main/services/skill-registry-core.test.ts main/services/skill-registry.test.ts main/services/skill-tools.test.ts main/services/skills-discovery.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/assistant/use-assistant-chat.test.ts renderer/components/composer.test.tsx renderer/components/message-bubble.test.tsx renderer/lib/chat-copy-view.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/computer-use-control.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/skill-catalog-workspace.test.ts renderer/lib/slash-command-actions.test.ts renderer/lib/slash-command-core.test.ts renderer/lib/slash-command-performance.test.ts renderer/main/chat-transition.test.tsx renderer/shared/attachment-contract.test.ts renderer/shared/chat-message-contract.test.ts renderer/shared/slash-commands.test.ts", + "test:slash-commands": "tsx --test main/services/generation-initialization-terminal.test.ts main/handlers/attachments.contract.test.ts main/handlers/chat.parse.test.ts main/handlers/chat-create-params.test.ts main/handlers/chat-session-params.test.ts main/handlers/worktree-create-params.test.ts main/services/chat-workspace-authority.test.ts main/handlers/chats.append.contract.test.ts main/services/attachment-contract.test.ts main/services/attachments.test.ts main/services/chat-append-commit.test.ts main/services/chat-export.test.ts main/services/chat-message-contract.test.ts main/services/chat-session-copy.test.ts main/services/chat-store-core.test.ts main/services/chat-turn-admission.test.ts main/services/generation-messages.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/scheduled-chat-creation.test.ts main/services/skill-invocation-flow.integration.test.ts main/services/skill-invocation-turn.test.ts main/services/skill-registry-core.test.ts main/services/skill-registry.test.ts main/services/skill-tools.test.ts main/services/skills-discovery.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/assistant/use-assistant-chat.test.ts renderer/components/composer.test.tsx renderer/components/message-bubble.test.tsx renderer/lib/chat-copy-view.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/computer-use-control.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/skill-catalog-workspace.test.ts renderer/lib/slash-command-actions.test.ts renderer/lib/slash-command-core.test.ts renderer/lib/slash-command-performance.test.ts renderer/main/chat-transition.test.tsx renderer/shared/attachment-contract.test.ts renderer/shared/chat-message-contract.test.ts renderer/shared/slash-commands.test.ts renderer/components/settings/skills-settings.test.tsx", "test:display-image": "tsx --test main/services/display-image-artifact-store.test.ts main/services/display-image-extension.test.ts main/services/generation-timeline.test.ts renderer/components/message-bubble.test.tsx renderer/lib/ipc-stream.test.ts", "test:ask-user-question": "tsx --test renderer/shared/ask-user-question.test.ts main/services/ask-user-question-coordinator.test.ts main/services/ask-user-question-extension.test.ts renderer/components/ask-user-question-composer.test.ts", "test:todo": "tsx --test main/services/rpiv-todo/*.test.ts renderer/shared/todo.test.ts renderer/components/todo-panel.test.tsx main/services/generation-timeline.test.ts main/handlers/ipc-contract.test.ts renderer/lib/ipc-stream.test.ts", @@ -109,7 +109,7 @@ "test:voice": "tsx --test main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts", "test:diagnostics": "tsx --test main/services/diagnostics-contract.test.ts main/services/diagnostic-health.test.ts main/services/diagnostic-journal.test.ts main/services/diagnostic-support.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/renderer-crash-recovery.test.ts main/services/renderer-diagnostic-rate.test.ts main/services/subagents/subagent-runtime-diagnostics.test.ts renderer/components/settings/diagnostics-settings.test.tsx && node --test scripts/diagnostic-policy.test.mjs", "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", - "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native", + "test": "tsx --test main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/scrollbar-gutter-contract.test.ts renderer/lib/text-entry-focus-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/components/interface-polish.test.tsx renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native && npm run test:settings-design", "test:coverage": "tsx --test --experimental-test-coverage main/services/mcp-oauth-client-metadata.test.ts main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/dictation-cleanup-core.test.ts main/services/dictation-hotkey.test.ts main/services/dictation-key-state.test.ts main/services/dictation-keycode.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts renderer/shared/plugin-catalog.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/media-recorder-stop.test.ts renderer/lib/dictation-vad.test.ts renderer/lib/dictation-sounds.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/components/settings/mcp-settings.test.tsx renderer/components/settings/mcp-preset-icons.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", @@ -135,7 +135,8 @@ "pretest:vcc": "npm run build:vcc", "test:vcc": "tsx --test main/services/pi-vcc/*.test.ts renderer/shared/compaction.test.ts", "prevcc:evaluate": "npm run build:vcc", - "vcc:evaluate": "node --import tsx scripts/pi-vcc-evaluation.mjs" + "vcc:evaluate": "node --import tsx scripts/pi-vcc-evaluation.mjs", + "test:settings-design": "tsx --test renderer/lib/workspace-path-display.test.ts renderer/components/settings/settings-design.test.tsx renderer/shared/appearance.test.ts renderer/components/chat-sidebar.test.tsx renderer/components/settings/memory-settings.test.tsx" }, "dependencies": { "@earendil-works/pi-agent-core": "0.84.4", diff --git a/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json index ca79561b..8a433ff3 100644 --- a/protocol/aiden-remote/v1/fixtures/contract.json +++ b/protocol/aiden-remote/v1/fixtures/contract.json @@ -598,6 +598,7 @@ } }, "botCapabilityCatalog": { + "skillsEnabled": true, "revision": "bot_catalog_revision_3", "providers": [ { diff --git a/protocol/aiden-remote/v1/openapi.json b/protocol/aiden-remote/v1/openapi.json index 7044039a..0d36d3d1 100644 --- a/protocol/aiden-remote/v1/openapi.json +++ b/protocol/aiden-remote/v1/openapi.json @@ -1253,6 +1253,9 @@ "operationId": "getBotCapabilityCatalog", "x-aiden-capability": "bot:read", "x-aiden-capabilities": ["bot:read"], + "parameters": [ + { "$ref": "#/components/parameters/BotIdQuery" } + ], "responses": { "200": { "description": "Safe opaque Bot capability catalog; no paths, credentials, headers, or fingerprints", @@ -3299,6 +3302,18 @@ "pattern": "^[A-Za-z0-9._:-]+$" } }, + "BotIdQuery": { + "name": "botId", + "in": "query", + "required": false, + "description": "Existing authenticated Bot whose saved unavailable catalog choices should be retained; omit for the generic Bot-creation catalog.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9._:-]+$" + } + }, "AssetRevision": { "name": "assetRevision", "in": "path", @@ -5033,6 +5048,11 @@ "maxItems": 128, "items": { "$ref": "#/components/schemas/BotCapabilityOption" } }, + "skillsEnabled": { + "type": "boolean", + "default": true, + "description": "Optional global Skills gate; omission means enabled. When false, skill choices are unavailable and only authenticated retained selections may be preserved or reduced. This flag never grants runtime skill authority." + }, "skills": { "type": "array", "maxItems": 256, diff --git a/renderer/components/chat-sidebar.test.tsx b/renderer/components/chat-sidebar.test.tsx index cc6b7471..c4330e02 100644 --- a/renderer/components/chat-sidebar.test.tsx +++ b/renderer/components/chat-sidebar.test.tsx @@ -192,13 +192,14 @@ test("chat shortcuts follow the rows rendered by the active organization", () => test("workspace actions and destructive confirmations disambiguate duplicate names", () => { const sidebar = source("./chat-sidebar.tsx"); - assert.match(sidebar, /function workspaceSecondaryLabel/u); + assert.match(sidebar, /useWorkspacePathPreferences/u); + assert.match(source("../lib/workspace-path-display.ts"), /function workspaceSecondaryLabel/u); assert.match( sidebar, - /ariaLabel=\{`Actions for \$\{workspaceAccessibleName\(group\.workspace\)\}`\}/u, + /ariaLabel=\{`Actions for \$\{workspaceAccessibleName\(group\.workspace, pathPreferences, workspaces\)\}`\}/u, ); - assert.match(sidebar, /Target: \{workspaceSecondaryLabel\(deletingWorktree\)\}/u); - assert.match(sidebar, /workspaceSecondaryLabel\(removingWorkspace\)/u); + assert.match(sidebar, /Target: \{deletingWorktree\.folderPath \?\? deletingWorktree\.name\}/u); + assert.match(sidebar, /removingWorkspace\.folderPath \?\? removingWorkspace\.name/u); }); test("sidebar overflow menus open beyond the sidebar's right edge", () => { @@ -228,7 +229,7 @@ test("sidebar overflow menus open beyond the sidebar's right edge", () => { assert.match(sidebar, /ariaLabel="Add workspace"[\s\S]{0,240}triggerIcon=\{\}/u); assert.match( sidebar, - /ariaLabel=\{`Actions for \$\{workspaceAccessibleName\(group\.workspace\)\}`\}/u, + /ariaLabel=\{`Actions for \$\{workspaceAccessibleName\(group\.workspace, pathPreferences, workspaces\)\}`\}/u, ); }); @@ -328,7 +329,7 @@ test("allocated composer and settings widths drive their compact layouts", () => const settings = source("../main/settings-view.tsx"); const styles = source("../styles.css"); assert.match(composer, /className="composer-responsive pointer-events-auto relative isolate"/u); - assert.match(settings, /className="settings-responsive mx-auto w-full max-w-2xl/u); + assert.match(settings, /className="settings-responsive mx-auto w-full max-w-5xl/u); assert.match(styles, /\.composer-responsive\s*\{\s*container: composer \/ inline-size;/u); assert.match(styles, /@container composer \(max-width: 520px\)/u); assert.match(styles, /\.settings-responsive\s*\{\s*container: settings-content \/ inline-size;/u); diff --git a/renderer/components/chat-sidebar.tsx b/renderer/components/chat-sidebar.tsx index 4252344c..e589c1c6 100644 --- a/renderer/components/chat-sidebar.tsx +++ b/renderer/components/chat-sidebar.tsx @@ -2,6 +2,9 @@ // projections, route-driven selection, and workspace/chat management actions. import * as React from "react"; +import { workspaceDisplayName, workspaceSecondaryLabel, type WorkspacePathPreferences } from "../lib/workspace-path-display"; +import { WorkspacePathLabel } from "./workspace-path-label"; +import { useWorkspacePathPreferences } from "../lib/use-workspace-path-preferences"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -89,17 +92,8 @@ interface ChatSidebarProps { titleReveal?: ChatTitleRevealEvent | null; } -function workspaceSecondaryLabel(workspace: Workspace): string { - if (workspace.managedWorktree?.branch) { - return workspace.folderPath - ? `${workspace.managedWorktree.branch} · ${workspace.folderPath}` - : workspace.managedWorktree.branch; - } - return workspace.folderPath ?? `No folder · ${workspace.id.slice(0, 8)}`; -} - -function workspaceAccessibleName(workspace: Workspace): string { - return `${workspace.name}, ${workspaceSecondaryLabel(workspace)}`; +function workspaceAccessibleName(workspace: Workspace, preferences: WorkspacePathPreferences, workspaces: readonly Workspace[]): string { + return [workspaceDisplayName(workspace, workspaces), workspaceSecondaryLabel(workspace, preferences)].filter(Boolean).join(", "); } function SidebarOverflowMenu({ @@ -487,6 +481,7 @@ function groupChats(chats: ChatMeta[]): { label: string; chats: ChatMeta[] }[] { } export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { + const pathPreferences = useWorkspacePathPreferences(); const navigate = useNavigate(); const pathname = useRouterState({ select: (state) => state.location.pathname, @@ -1236,7 +1231,7 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { /> ) : ( projection.groups.map((group) => { - const secondaryLabel = workspaceSecondaryLabel(group.workspace); + const secondaryLabel = workspaceSecondaryLabel(group.workspace, pathPreferences); const expanded = Boolean(search.trim()) || expandedWorkspaceIds.has(group.workspace.id); const revealAll = @@ -1258,18 +1253,21 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { } title={ - {group.workspace.name} - - {secondaryLabel} - + {workspaceDisplayName(group.workspace, workspaces)} + {group.workspace.folderPath && pathPreferences.showWorkspacePaths ? ( + + {group.workspace.managedWorktree?.branch ? {group.workspace.managedWorktree.branch} · : null} + + + ) : secondaryLabel ? {secondaryLabel} : null} } - aria-label={`${expanded ? "Collapse" : "Expand"} ${workspaceAccessibleName(group.workspace)}`} + aria-label={`${expanded ? "Collapse" : "Expand"} ${workspaceAccessibleName(group.workspace, pathPreferences, workspaces)}`} aria-expanded={expanded} onClick={() => toggleWorkspace(group.workspace.id)} /> @@ -1470,7 +1468,7 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { The clean checkout for “{deletingWorktree.name}” will be removed. Its branch is deleted only if it has no commits beyond where Aiden created it. Chats stay on disk. - Dirty worktrees are refused. Target: {workspaceSecondaryLabel(deletingWorktree)}. + Dirty worktrees are refused. Target: {deletingWorktree.folderPath ?? deletingWorktree.name}. {environmentPanel.editorState.workspaceId === deletingWorktree.id && environmentPanel.editorState.dirty ? ` The unsaved edit to ${environmentPanel.editorState.path ?? "the open file"} will be discarded.` @@ -1494,7 +1492,7 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { “{removingWorkspace.name}” will be removed. Its chats stay on disk but won’t be listed. The folder itself is not touched. Target:{" "} - {workspaceSecondaryLabel(removingWorkspace)}. + {removingWorkspace.folderPath ?? removingWorkspace.name}. {environmentPanel.editorState.workspaceId === removingWorkspace.id && environmentPanel.editorState.dirty ? ` The unsaved edit to ${environmentPanel.editorState.path ?? "the open file"} will be discarded.` diff --git a/renderer/components/memory-card-icon.tsx b/renderer/components/memory-card-icon.tsx new file mode 100644 index 00000000..ef85bcdb --- /dev/null +++ b/renderer/components/memory-card-icon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from "react"; + +/** SD-card silhouette, using the same stroke and sizing as the app's Lucide icons. */ +export function MemoryCardIcon(props: SVGProps) { + return ( + + ); +} diff --git a/renderer/components/onboarding-flow.test.tsx b/renderer/components/onboarding-flow.test.tsx index 624db126..290d6690 100644 --- a/renderer/components/onboarding-flow.test.tsx +++ b/renderer/components/onboarding-flow.test.tsx @@ -317,7 +317,7 @@ test("the final step is a complete grouped bento gallery with hover descriptions ); assert.match( source, - /Create reusable instructions, then type \$ to attach one to your next message\./u, + /Create reusable instructions, then type \$ to attach one\. Turn all skills off anytime in Settings → Skills\./u, ); assert.match( source, diff --git a/renderer/components/onboarding-flow.tsx b/renderer/components/onboarding-flow.tsx index 58c6a736..a75c1ce7 100644 --- a/renderer/components/onboarding-flow.tsx +++ b/renderer/components/onboarding-flow.tsx @@ -1,7 +1,7 @@ import { Bot, Blocks, - BrainCircuit, + Lightbulb, CalendarClock, ChartBar, ChartScatter, @@ -273,7 +273,7 @@ const featureBentos: FeatureBento[] = [ group: "extend", title: "Thinking Controls", description: "Tune supported models' reasoning effort and follow thinking as it streams.", - icon: BrainCircuit, + icon: Lightbulb, imageUrl: FEATURE_ILLUSTRATIONS.thinking, size: "standard", }, @@ -301,7 +301,7 @@ const featureBentos: FeatureBento[] = [ id: "skills", group: "extend", title: "Reusable Skills", - description: "Create reusable instructions, then type $ to attach one to your next message.", + description: "Create reusable instructions, then type $ to attach one. Turn all skills off anytime in Settings → Skills.", icon: Wand2, imageUrl: FEATURE_ILLUSTRATIONS.skills, size: "wide", diff --git a/renderer/components/settings/about-settings.tsx b/renderer/components/settings/about-settings.tsx index 77a5436a..d7530a9f 100644 --- a/renderer/components/settings/about-settings.tsx +++ b/renderer/components/settings/about-settings.tsx @@ -156,7 +156,7 @@ export function AboutSettings() { return ( <> -
+
diff --git a/renderer/components/settings/appearance-settings.tsx b/renderer/components/settings/appearance-settings.tsx index a537f133..197bb20d 100644 --- a/renderer/components/settings/appearance-settings.tsx +++ b/renderer/components/settings/appearance-settings.tsx @@ -436,6 +436,19 @@ function Preferences({ >

Preferences

+ + onChange({ showWorkspacePaths: checked })} aria-label="Show workspace folder paths" /> + + + + onChange({ pointerCursors: checked })} aria-label="Use pointer cursors" /> @@ -746,7 +759,7 @@ export function AppearanceSettings() { return (
-
+

Appearance

Shape Aiden’s light and dark interfaces independently. Changes apply live.

diff --git a/renderer/components/settings/computer-use-settings.tsx b/renderer/components/settings/computer-use-settings.tsx index 0b06879a..1b798f67 100644 --- a/renderer/components/settings/computer-use-settings.tsx +++ b/renderer/components/settings/computer-use-settings.tsx @@ -130,7 +130,7 @@ export function ComputerUseSettings() {
- Computer Use Beta + Desktop control Beta } > diff --git a/renderer/components/settings/gemini-voice-setup-dialog.tsx b/renderer/components/settings/gemini-voice-setup-dialog.tsx index 52d380df..85d85f6e 100644 --- a/renderer/components/settings/gemini-voice-setup-dialog.tsx +++ b/renderer/components/settings/gemini-voice-setup-dialog.tsx @@ -1,4 +1,4 @@ -import { Brain, KeyRound, Mic2, ShieldCheck } from "lucide-react"; +import { AudioLines, KeyRound, Mic2, ShieldCheck } from "lucide-react"; import { Button, Dialog, RadioGroup, RadioGroupItem, Text, type DialogLayer } from "../ui"; import type { GeminiUsageScope } from "../../lib/types"; @@ -36,7 +36,7 @@ const choices: Array<{ scope: "models_and_transcription", title: "Models + transcription", description: "Use Gemini for voice and add Google chat models throughout Aiden.", - icon: Brain, + icon: AudioLines, }, ]; diff --git a/renderer/components/settings/mcp-settings.tsx b/renderer/components/settings/mcp-settings.tsx index fede21af..960c9dc6 100644 --- a/renderer/components/settings/mcp-settings.tsx +++ b/renderer/components/settings/mcp-settings.tsx @@ -123,9 +123,9 @@ export function McpSettings() { return (
-
+
- Plugins + Plugins Browse plugins, connect hosted MCP servers, or add your own. Listing a plugin does not add tools. Enabled MCP servers and Skills you add become assistant tools; workspace @@ -147,7 +147,7 @@ export function McpSettings() {
-
+
Manual MCP server setup @@ -166,7 +166,7 @@ export function McpSettings() { Configured MCP servers · {list.length} -
+
{list.map((s, i) => ( {i > 0 ? : null} @@ -408,7 +408,7 @@ function PluginCard({ const connectable = isConnectablePlugin(plugin); const badge = state ? mcpPresetConnectionBadge(state) : null; return ( -
+
-
+
assert.match(source, /enlarge the window to view it beside the Pad/u); assert.match(source, /scrollIntoView/u); assert.match(source, /dataset\.reduceMotion === "true"/u); - assert.match( - styles, - /--model-pad-fieldset-width: max\(100%, min\(64rem, calc\(100vw - 19rem\)\)\)/u, - ); + assert.doesNotMatch(styles, /--model-pad-fieldset-width/u); + assert.match(styles, /\.model-pad-fieldset\s*\{\s*width: 100%;\s*min-width: 0/u); assert.match(styles, /\.model-pad-field\s*\{\s*padding: 1\.5rem/u); assert.match( styles, - /\.settings-model-pad-grid\[data-panel-open="true"\]\s*\{\s*grid-template-columns: minmax\(28rem, 40rem\) minmax\(16rem, 18rem\)/u, + /\.settings-model-pad-grid\[data-panel-open="true"\]\s*\{\s*grid-template-columns: minmax\(0, 1fr\) minmax\(16rem, 18rem\)/u, ); assert.match(styles, /\.model-pad-canvas\s*\{\s*width: min\(100%, 40rem\)/u); + assert.match(styles, /\.model-pad\s*\{\s*width: min\(100%, var\(--model-pad-available-size/u); assert.match(styles, /@container model-pad-fieldset \(max-width: 760px\)/u); assert.match( styles, - /\.model-pad-catalog\s*\{\s*max-height: min\(48rem, calc\(100vh - 12rem\)\)/u, + /\.model-pad-catalog\s*\{\s*max-height: max\(8rem, var\(--model-pad-available-size/u, ); assert.match(styles, /\.model-pad-catalog-shell\[data-more-below="true"\]::after/u); assert.match(styles, /\.model-pad-catalog-more/u); diff --git a/renderer/components/settings/model-pad-settings.tsx b/renderer/components/settings/model-pad-settings.tsx index 9063f2ca..597d729e 100644 --- a/renderer/components/settings/model-pad-settings.tsx +++ b/renderer/components/settings/model-pad-settings.tsx @@ -372,6 +372,53 @@ export function ModelPadSettings() { const catalogRef = React.useRef(null); const modelsPanelRef = React.useRef(null); const panelTransitionRef = React.useRef(null); + + React.useLayoutEffect(() => { + const pad = padRef.current; + const canvas = pad?.parentElement; + const grid = canvas?.parentElement; + if (!pad || !canvas || !grid) return; + + // Measure the real settings viewport: window width alone ignores the app + // sidebar, settings navigation, wrapped controls, and user interface zoom. + let scrollport = grid.parentElement; + while (scrollport && !/(auto|scroll)/u.test(getComputedStyle(scrollport).overflowY)) { + scrollport = scrollport.parentElement; + } + let frame = 0; + const measure = () => { + frame = 0; + const viewportBottom = Math.min( + window.innerHeight, + scrollport?.getBoundingClientRect().bottom ?? window.innerHeight, + ); + // Compensate for page scrolling so scrolling down never grows the Pad. + const canvasTop = canvas.getBoundingClientRect().top + (scrollport?.scrollTop ?? 0); + const labelsHeight = canvas.getBoundingClientRect().height - pad.getBoundingClientRect().height; + const size = Math.floor(Math.max(160, viewportBottom - canvasTop - labelsHeight - 24)); + const value = `${size}px`; + if (grid.style.getPropertyValue("--model-pad-available-size") !== value) { + grid.style.setProperty("--model-pad-available-size", value); + } + }; + const schedule = () => { + if (!frame) frame = requestAnimationFrame(measure); + }; + const observer = new ResizeObserver(schedule); + // Include ancestors to catch a title or toolbar wrapping after a font or + // window change, and labels to converge when a narrow legend wraps. + for (let element: HTMLElement | null = canvas; element; element = element.parentElement) { + observer.observe(element); + if (element === scrollport) break; + } + measure(); + window.addEventListener("resize", schedule); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener("resize", schedule); + }; + }, [activePanel]); const [catalogScrollState, setCatalogScrollState] = React.useState({ scrollable: false, hasMoreBelow: false, @@ -644,10 +691,8 @@ export function ModelPadSettings() { const activeRow = activePoint ? Math.round((1 - activePoint.y) * (gridSize - 1)) : -1; return ( -
+
diff --git a/renderer/components/settings/providers-settings.tsx b/renderer/components/settings/providers-settings.tsx index 3accf1cd..cbdb6037 100644 --- a/renderer/components/settings/providers-settings.tsx +++ b/renderer/components/settings/providers-settings.tsx @@ -333,10 +333,10 @@ export function ProvidersSettings() { return (
-
+
- Providers + Providers Aiden manages built-in provider endpoints and model catalogs. Use Add provider for a local, private, or vendor-compatible endpoint. @@ -423,7 +423,7 @@ export function ProvidersSettings() {
-
+
{catalogOutcome ?? @@ -470,7 +470,7 @@ export function ProvidersSettings() { {foundationModels.data ? (
@@ -549,7 +549,7 @@ export function ProvidersSettings() {
) : null} -
+
Built into Aiden @@ -563,7 +563,7 @@ export function ProvidersSettings() { Connect with credentials when required; Aiden keeps their model catalogs current.
-
+
{showMoreBuiltinProviders && moreBuiltins.length > 0 ? ( -
+
More built-in providers @@ -610,14 +610,14 @@ export function ProvidersSettings() {
{customProviders.length > 0 ? ( -
+
Custom connections Configure local, private, and vendor-compatible endpoints here.
-
+
{customProviders.map((p, i) => ( {i > 0 ? : null} diff --git a/renderer/components/settings/remote-access-settings.tsx b/renderer/components/settings/remote-access-settings.tsx index 86bca52f..6ed4f86d 100644 --- a/renderer/components/settings/remote-access-settings.tsx +++ b/renderer/components/settings/remote-access-settings.tsx @@ -151,7 +151,7 @@ function Disclosure({ children, }: React.PropsWithChildren<{ title: string; summary: string }>) { return ( -
+
{title} @@ -462,7 +462,7 @@ export function RemoteAccessSettings() { if (settingsQuery.isLoading) { return ( -
+
Checking… @@ -516,7 +516,7 @@ export function RemoteAccessSettings() { return ( <> -
+
diff --git a/renderer/components/settings/scheduled-tasks-settings.tsx b/renderer/components/settings/scheduled-tasks-settings.tsx index 825c4265..16d5fb3c 100644 --- a/renderer/components/settings/scheduled-tasks-settings.tsx +++ b/renderer/components/settings/scheduled-tasks-settings.tsx @@ -76,7 +76,7 @@ export function ScheduledTasksSettings() { ) : null} -
+
readFileSync(new URL(path, import.meta.url), "utf8"); + +test("every settings destination uses the shared page and grouped row system", () => { + const view = read("../../main/settings-view.tsx"); + assert.match(view, /[\s\S]*<\/SettingsPage>/u); + for (const page of ["appearance", "providers", "mcp", "skills", "shortcut", "web-search"]) { + assert.match(read(`./${page}-settings.tsx`), /settings-page-heading/u); + } + assert.match( + read("./web-search-settings.tsx"), + /
`)); + } + const ui = read("../ui.tsx"); + assert.match(ui, /settings-group-card/u); + assert.match(ui, /settings-field-control/u); + const css = read("../../styles.css"); + assert.match(css, /--settings-card-fill:/u); + assert.match( + css, + /settings-field-horizontal:has\(> \.settings-field-control > \[role="switch"\]\)/u, + ); + assert.match(css, /outline: 2px solid var\(--focus-ring\)/u); +}); + +test("Memory uses an SD-card silhouette and settings/onboarding use no brain icons", () => { + assert.match(read("../../main/settings-view.tsx"), /memory: { + const telegram = read("./telegram-settings.tsx"); + for (const label of ["Enable Telegram bridge", "Live answer drafts", "Private-chat threads"]) { + assert.ok(telegram.includes(`aria-label="${label}"`)); + } + assert.doesNotMatch(telegram, /Settings → Workspaces/u); +}); diff --git a/renderer/components/settings/settings-page.tsx b/renderer/components/settings/settings-page.tsx new file mode 100644 index 00000000..a9444030 --- /dev/null +++ b/renderer/components/settings/settings-page.tsx @@ -0,0 +1,21 @@ +import type { PropsWithChildren } from "react"; + +/** Shared Settings page hierarchy. FieldSet and Field supply its grouped cards and rows. */ +export function SettingsPage({ + title, + description, + heading = true, + children, +}: PropsWithChildren<{ title: string; description: string; heading?: boolean }>) { + return ( +
+ {heading ? ( +
+

{title}

+

{description}

+
+ ) : null} + {children} +
+ ); +} diff --git a/renderer/components/settings/shortcut-settings.tsx b/renderer/components/settings/shortcut-settings.tsx index 98f181c1..0fdfaa08 100644 --- a/renderer/components/settings/shortcut-settings.tsx +++ b/renderer/components/settings/shortcut-settings.tsx @@ -296,7 +296,7 @@ export function ShortcutSettings() { return (
-
+
Keyboard shortcuts diff --git a/renderer/components/settings/skills-settings.test.tsx b/renderer/components/settings/skills-settings.test.tsx new file mode 100644 index 00000000..6cfce986 --- /dev/null +++ b/renderer/components/settings/skills-settings.test.tsx @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("./skills-settings.tsx", import.meta.url), "utf8"); +const handlers = readFileSync( + new URL("../../../main/handlers/providers.ts", import.meta.url), + "utf8", +); + +test("Skills exposes a persisted global switch with a right-side control and failure recovery", () => { + assert.match(source, /label="Use skills globally"[\s\S]*orientation="horizontal"/u); + assert.match(source, /settingsApi\.set\(\{ skillsEnabled: enabled \}\)/u); + assert.match(source, /disabled=\{globalSaving \|\| !settings.data\}/u); + assert.match(source, /setQueryData\(queryKeys.settings, saved\)/u); + assert.match(source, /toast\.error/u); + assert.match(source, /Visible chat history stays available/u); + assert.match(source, /disabled=\{!globallyEnabled\}/u); +}); + +test("the settings boundary invalidates cached Bot and chat skills and stops old active replies", () => { + assert.match(handlers, /typeof p.skillsEnabled !== "boolean"/u); + assert.match(handlers, /skillRegistry\.invalidate\(\)/u); + assert.match(handlers, /invalidateBotRuntimeInventoryAuthority\("skill_configuration"\)/u); + assert.match(handlers, /llmClient\.cancelForSkillsDisabled\(\)/u); +}); + + +test("a skill attachment prepared before disabling is checked again before prompt injection", () => { + const runtime = readFileSync(new URL("../../../main/services/llm-client.ts", import.meta.url), "utf8"); + assert.match(runtime, /initialization\.skillPrompt[\s\S]*getSettings\(\)\)\.skillsEnabled === false[\s\S]*contentOverrides\.set\(currentUser\.id, initialization\.skillPrompt\)/u); +}); diff --git a/renderer/components/settings/skills-settings.tsx b/renderer/components/settings/skills-settings.tsx index d05ee16c..4bba1af0 100644 --- a/renderer/components/settings/skills-settings.tsx +++ b/renderer/components/settings/skills-settings.tsx @@ -15,11 +15,18 @@ import { Switch, Text, Textarea, + toast, } from "../ui"; import { FolderGit2, Plus, Trash2 } from "lucide-react"; -import { skillsApi } from "../../lib/ipc"; -import { queryKeys, useDiscoveredSkills, useSkills, useWorkspaces } from "../../lib/queries"; -import type { Skill } from "../../lib/types"; +import { skillsApi, settingsApi } from "../../lib/ipc"; +import { + queryKeys, + useDiscoveredSkills, + useSkills, + useWorkspaces, + useSettings, +} from "../../lib/queries"; +import type { Skill, AppSettings } from "../../lib/types"; import { resolveSkillCatalogWorkspaceId } from "../../lib/skill-catalog-workspace"; /** Active workspace id from localStorage (settings sits outside WorkspaceProvider). */ @@ -43,6 +50,9 @@ function newSkill(): Skill { export function SkillsSettings() { const qc = useQueryClient(); const skills = useSkills(); + const settings = useSettings(); + const [globalSaving, setGlobalSaving] = React.useState(false); + const globallyEnabled = settings.data?.skillsEnabled !== false; const [editing, setEditing] = React.useState(null); const [removing, setRemoving] = React.useState(null); @@ -55,6 +65,20 @@ export function SkillsSettings() { qc.invalidateQueries({ queryKey: ["skillCatalog"] }), ]); + const setGlobalEnabled = async (enabled: boolean) => { + if (globalSaving) return; + setGlobalSaving(true); + try { + const saved = await settingsApi.set({ skillsEnabled: enabled }); + qc.setQueryData(queryKeys.settings, saved); + await invalidate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Couldn’t update skills settings."); + } finally { + setGlobalSaving(false); + } + }; + const toggle = async (skill: Skill, enabled: boolean) => { await skillsApi.save({ ...skill, enabled }); await invalidate(); @@ -65,9 +89,11 @@ export function SkillsSettings() { return (
-
+
- Skills + + Skills + Reusable instruction sets the assistant can invoke as tools when a task matches. @@ -83,13 +109,34 @@ export function SkillsSettings() {
+
+ + + +
+ {!globallyEnabled ? ( + + Skills are off. Aiden won’t discover, attach, or load skills until you turn them on again. + Visible chat history stays available. Hidden skill instructions won’t be replayed. + + ) : null} + {list.length === 0 ? ( No skills yet. Create one — e.g. “Code Reviewer” with your review checklist as its instructions. ) : ( -
+
{list.map((s, i) => ( {i > 0 ? : null} @@ -108,6 +155,7 @@ export function SkillsSettings() { toggle(s, v)} /> -
+

Back to Web Search -
+

-
+
Web Search diff --git a/renderer/components/ui.tsx b/renderer/components/ui.tsx index eb6b1029..c6248654 100644 --- a/renderer/components/ui.tsx +++ b/renderer/components/ui.tsx @@ -305,9 +305,9 @@ export function FieldSet({ children, }: React.PropsWithChildren<{ title?: React.ReactNode; className?: string }>) { return ( -
- {title ?

{title}

: null} -
{children}
+
+ {title ?

{title}

: null} +
{children}
); } @@ -332,7 +332,7 @@ export function Field({ aria-labelledby={label ? labelId : undefined} aria-describedby={description ? descriptionId : undefined} className={cn( - "relative p-4 after:absolute after:inset-x-4 after:bottom-0 after:h-px after:bg-separator last:after:hidden", + "settings-field relative p-4 after:absolute after:inset-x-4 after:bottom-0 after:h-px after:bg-separator last:after:hidden", orientation === "horizontal" ? "settings-field-horizontal grid min-h-12 grid-cols-[minmax(120px,0.8fr)_minmax(160px,1.2fr)] items-center gap-5 max-[540px]:grid-cols-1 max-[540px]:items-start max-[540px]:gap-2" : "flex flex-col gap-3", @@ -351,7 +351,7 @@ export function Field({
) : null}

-
{children}
+
{children}
); } diff --git a/renderer/components/workspace-path-label.tsx b/renderer/components/workspace-path-label.tsx new file mode 100644 index 00000000..17e3cccc --- /dev/null +++ b/renderer/components/workspace-path-label.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import { formatWorkspacePath } from "../lib/workspace-path-display"; +import type { AppearanceConfig } from "../shared/appearance"; + +/** Fit the selected truncation style to the real label width, including UI font/zoom changes. */ +export function WorkspacePathLabel({ + path, + format, +}: { + path: string; + format: AppearanceConfig["workspacePathFormat"]; +}) { + const ref = React.useRef(null); + const [label, setLabel] = React.useState(() => formatWorkspacePath(path, format, 24)); + React.useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + const context = document.createElement("canvas").getContext("2d"); + if (!context) return; + const update = () => { + context.font = getComputedStyle(element).font; + const width = element.clientWidth; + // Paths longer than a label could ever show need only a bounded search. + let low = 0; + let high = Math.min(path.length, 512); + let next = ""; + while (low <= high) { + const budget = Math.floor((low + high) / 2); + const candidate = formatWorkspacePath(path, format, budget); + if (context.measureText(candidate).width <= width) { + next = candidate; + low = budget + 1; + } else { + high = budget - 1; + } + } + setLabel(next); + }; + const observer = new ResizeObserver(update); + observer.observe(element); + // Fonts can change without the row's width changing. + document.fonts.addEventListener("loadingdone", update); + window.addEventListener("aiden:appearance-changed", update); + update(); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", update); + window.removeEventListener("aiden:appearance-changed", update); + }; + }, [path, format]); + return ( + + {label} + + ); +} diff --git a/renderer/components/workspace-picker.tsx b/renderer/components/workspace-picker.tsx index b1be0f05..af766c80 100644 --- a/renderer/components/workspace-picker.tsx +++ b/renderer/components/workspace-picker.tsx @@ -1,7 +1,9 @@ import * as React from "react"; import { Check, Folder, FolderX, Loader2 } from "lucide-react"; import type { Workspace } from "../lib/types"; -import { truncatePathMiddle } from "../lib/truncate-path"; +import { workspaceDisplayName } from "../lib/workspace-path-display"; +import { WorkspacePathLabel } from "./workspace-path-label"; +import { useWorkspacePathPreferences } from "../lib/use-workspace-path-preferences"; import { Command, CommandEmpty, @@ -32,6 +34,7 @@ export function WorkspacePicker({ onCreateScratchWorkspace, blockedReason, }: WorkspacePickerProps) { + const pathPreferences = useWorkspacePathPreferences(); const [open, setOpen] = React.useState(false); const [pending, setPending] = React.useState(null); const blockedReasonId = React.useId(); @@ -108,15 +111,10 @@ export function WorkspacePicker({ - {workspace.name} + {workspaceDisplayName(workspace, workspaces)} - {workspace.folderPath ? ( - - {truncatePathMiddle(workspace.folderPath)} - + {workspace.folderPath && pathPreferences.showWorkspacePaths ? ( + ) : null} {pending === workspace.id ? ( diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 9ac86617..e89f51fe 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -859,7 +859,7 @@ export const botsApi = { cancelAvatarSuggestion: (requestId: string) => invoke("bots:cancelAvatarSuggestion", requestId), update: (input: BotUpdateInput) => invoke("bots:update", input), - getCapabilityCatalog: () => invoke("bots:getCapabilityCatalog"), + getCapabilityCatalog: (botId?: string) => invoke("bots:getCapabilityCatalog", botId), getBotAccess: (id: string) => invoke("bots:getBotAccess", id), updateBotAccess: (input: { botId: string; expectedRevision: string; access: BotAccessUpdate }) => invoke("bots:updateBotAccess", input), diff --git a/renderer/lib/queries.ts b/renderer/lib/queries.ts index d937a240..71a4c0a8 100644 --- a/renderer/lib/queries.ts +++ b/renderer/lib/queries.ts @@ -275,10 +275,10 @@ export function useBotChats(botId: string | undefined) { } /** Bot capability catalog for the desktop audience; refreshed after saves. */ -export function useBotCapabilityCatalog(enabled: boolean) { +export function useBotCapabilityCatalog(enabled: boolean, botId?: string) { return useQuery({ - queryKey: queryKeys.botCapabilityCatalog, - queryFn: () => botsApi.getCapabilityCatalog(), + queryKey: [...queryKeys.botCapabilityCatalog, botId], + queryFn: () => botsApi.getCapabilityCatalog(botId), enabled, }); } diff --git a/renderer/lib/truncate-path.test.ts b/renderer/lib/truncate-path.test.ts index 7f47743b..453cdffe 100644 --- a/renderer/lib/truncate-path.test.ts +++ b/renderer/lib/truncate-path.test.ts @@ -36,7 +36,7 @@ test("falls back to character middle truncation for separator-less strings", () test("handles tiny budgets and empty input", () => { assert.equal(truncatePathMiddle("/Users/long/path", 0), ""); assert.equal(truncatePathMiddle("/Users/long/path", 1), "…"); - assert.equal(truncatePathMiddle(" ", 10), ""); + assert.equal(truncatePathMiddle(" ", 10), " "); }); test("supports Windows-style separators", () => { diff --git a/renderer/lib/truncate-path.ts b/renderer/lib/truncate-path.ts index c9dd3267..24f0c40a 100644 --- a/renderer/lib/truncate-path.ts +++ b/renderer/lib/truncate-path.ts @@ -1,6 +1,22 @@ +/// + /** Default character budget for workspace path sublabels in menus (~max-w-72). */ export const DEFAULT_PATH_TRUNCATE_LENGTH = 44; +const pathSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +/** Keep whole graphemes (including emoji and combining marks) within a UTF-16 budget. */ +export function pathTextEdge(value: string, budget: number, edge: "start" | "end"): string { + const segments = Array.from(pathSegmenter.segment(value), ({ segment }) => segment); + if (edge === "end") segments.reverse(); + let result = ""; + for (const segment of segments) { + if (result.length + segment.length > budget) break; + result = edge === "end" ? segment + result : result + segment; + } + return result; +} + /** * Truncate a filesystem path with an ellipsis in the middle so both the * leading directories and the trailing leaf stay recognizable. @@ -12,7 +28,7 @@ export function truncatePathMiddle( path: string, maxLength: number = DEFAULT_PATH_TRUNCATE_LENGTH, ): string { - const value = path.trim(); + const value = path; if (maxLength <= 0) return ""; if (value.length <= maxLength) return value; @@ -59,7 +75,7 @@ export function truncatePathMiddle( const head = Math.ceil(budget / 2); const tail = Math.floor(budget / 2); - return `${value.slice(0, head)}${ellipsis}${value.slice(value.length - tail)}`; + return `${pathTextEdge(value, head, "start")}${ellipsis}${pathTextEdge(value, tail, "end")}`; } function formatPathEnds( diff --git a/renderer/lib/types.ts b/renderer/lib/types.ts index a654c500..c405eeb0 100644 --- a/renderer/lib/types.ts +++ b/renderer/lib/types.ts @@ -826,6 +826,8 @@ export interface AppSettings { showLocalModelReasoning?: boolean; computerUseEnabled?: boolean; /** Omitted in older configs; memory is enabled unless explicitly disabled. */ + /** Global skill discovery/invocation gate. Omitted means enabled. */ + skillsEnabled?: boolean; memoryEnabled?: boolean; scheduledTasksEnabled?: boolean; scheduledDefaultMode?: ScheduledTaskMode; diff --git a/renderer/lib/use-workspace-path-preferences.ts b/renderer/lib/use-workspace-path-preferences.ts new file mode 100644 index 00000000..2c87f08d --- /dev/null +++ b/renderer/lib/use-workspace-path-preferences.ts @@ -0,0 +1,24 @@ +import * as React from "react"; +import { APPEARANCE_CHANGE_EVENT, readCachedAppearance } from "./appearance-runtime"; +import { createDefaultAppearanceConfig, type AppearanceConfig } from "../shared/appearance"; + +/** Follow the same persisted and live-preview appearance state as the app shell. */ +export function useWorkspacePathPreferences() { + const [config, setConfig] = React.useState( + () => readCachedAppearance() ?? createDefaultAppearanceConfig(), + ); + React.useEffect(() => { + const update = (event: Event) => { + const detail = (event as CustomEvent<{ config: AppearanceConfig }>).detail; + setConfig(detail?.config ?? readCachedAppearance() ?? createDefaultAppearanceConfig()); + }; + window.addEventListener(APPEARANCE_CHANGE_EVENT, update); + window.addEventListener("storage", update); + update(new Event("refresh")); + return () => { + window.removeEventListener(APPEARANCE_CHANGE_EVENT, update); + window.removeEventListener("storage", update); + }; + }, []); + return config; +} diff --git a/renderer/lib/workspace-path-display.test.ts b/renderer/lib/workspace-path-display.test.ts new file mode 100644 index 00000000..fafd2e18 --- /dev/null +++ b/renderer/lib/workspace-path-display.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createDefaultAppearanceConfig, + normalizeAppearanceConfig, + parseAppearanceConfig, +} from "../shared/appearance"; +import { + formatWorkspacePath, + workspaceDisplayName, + workspaceSecondaryLabel, +} from "./workspace-path-display"; +import type { Workspace } from "./types"; + +const path = "/Users/xyz/work/long-parent/projects/aiden"; +const workspace = { id: "default", name: "Aiden", folderPath: path } as Workspace; + +test("duplicate workspace names get stable distinct path-free identities", () => { + const one = { ...workspace, id: "workspace-a1234" }; + const two = { ...workspace, id: "workspace-b1234", folderPath: "/other/private/folder" }; + assert.equal(workspaceDisplayName(one, [one]), "Aiden"); + assert.equal(workspaceDisplayName(one, [one, two]), "Aiden · a1234"); + assert.equal(workspaceDisplayName(two, [two, one]), "Aiden · b1234"); + assert.notEqual(workspaceDisplayName(one, [one, two]), workspaceDisplayName(two, [one, two])); +}); + +test("new and legacy profiles hide workspace paths by default", () => { + for (const preferences of [createDefaultAppearanceConfig(), normalizeAppearanceConfig({})]) { + assert.equal(preferences.showWorkspacePaths, false); + assert.equal(workspaceSecondaryLabel(workspace, preferences), ""); + } + const legacy = { ...createDefaultAppearanceConfig() } as Record; + delete legacy.showWorkspacePaths; + delete legacy.workspacePathFormat; + assert.equal(parseAppearanceConfig(legacy).showWorkspacePaths, false); +}); + +test("each format retains the requested portion and respects its budget", () => { + assert.equal(formatWorkspacePath(path, "middle", 26), "/Users/…/projects/aiden"); + assert.equal(formatWorkspacePath(path, "end", 20), "…/projects/aiden"); + assert.equal(formatWorkspacePath(path, "start", 20), "/Users/xyz/work/lon…"); + for (const format of ["middle", "end", "start"] as const) { + for (const length of [0, 1, 2, 7, 20, 44]) { + assert.ok(formatWorkspacePath(path, format, length).length <= length); + assert.ok( + formatWorkspacePath("C:\\Users\\xyz\\long-parent\\projects\\aiden", format, length) + .length <= length, + ); + } + assert.equal(formatWorkspacePath(" /aiden ", format), " /aiden "); + assert.equal(formatWorkspacePath("", format), ""); + } +}); + +test("formatting preserves legal whitespace and whole Unicode graphemes", () => { + for (const format of ["middle", "start", "end"] as const) { + assert.equal(formatWorkspacePath("/tmp/folder ", format, 99), "/tmp/folder "); + for (const path of ["/😀😀😀/project", "/e\u0301/e\u0301/e\u0301", "/👩‍💻👩‍💻/project"]) { + for (const length of [2, 3, 4, 8, 12]) { + const label = formatWorkspacePath(path, format, length); + assert.ok(label.length <= length); + assert.doesNotMatch( + label, + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const preferences = createDefaultAppearanceConfig(); + const worktree = { ...workspace, managedWorktree: { branch: "feature/test" } } as Workspace; + assert.equal(workspaceSecondaryLabel(worktree, preferences), "feature/test"); + assert.equal( + workspaceSecondaryLabel({ ...workspace, folderPath: undefined }, preferences), + "No folder · default", + ); + assert.equal( + workspaceSecondaryLabel(worktree, { ...preferences, showWorkspacePaths: true }), + `feature/test · ${path}`, + ); +}); + +test("preferences persist and malformed inputs cannot enable paths", () => { + for (const workspacePathFormat of ["middle", "end", "start"] as const) { + const preferences = { + ...createDefaultAppearanceConfig(), + showWorkspacePaths: true, + workspacePathFormat, + }; + assert.deepEqual(parseAppearanceConfig(JSON.parse(JSON.stringify(preferences))), preferences); + } + assert.equal( + normalizeAppearanceConfig({ showWorkspacePaths: "true", workspacePathFormat: "unknown" }) + .showWorkspacePaths, + false, + ); + assert.throws(() => + parseAppearanceConfig({ ...createDefaultAppearanceConfig(), showWorkspacePaths: "false" }), + ); + assert.throws(() => + parseAppearanceConfig({ ...createDefaultAppearanceConfig(), workspacePathFormat: "unknown" }), + ); +}); diff --git a/renderer/lib/workspace-path-display.ts b/renderer/lib/workspace-path-display.ts new file mode 100644 index 00000000..18955e57 --- /dev/null +++ b/renderer/lib/workspace-path-display.ts @@ -0,0 +1,64 @@ +import type { AppearanceConfig } from "../shared/appearance"; +import type { Workspace } from "./types"; +import { DEFAULT_PATH_TRUNCATE_LENGTH, pathTextEdge, truncatePathMiddle } from "./truncate-path"; + +export type WorkspacePathPreferences = Pick< + AppearanceConfig, + "showWorkspacePaths" | "workspacePathFormat" +>; + +/** A stable, path-free identity for duplicate names, independent of search/order. */ +export function workspaceDisplayName( + workspace: Workspace, + workspaces: readonly Workspace[], +): string { + const peers = workspaces.filter( + (entry) => + entry.id !== workspace.id && + entry.name.toLocaleLowerCase() === workspace.name.toLocaleLowerCase(), + ); + if (peers.length === 0) return workspace.name; + let length = Math.min(4, workspace.id.length); + while ( + length < workspace.id.length && + peers.some((entry) => entry.id.slice(-length) === workspace.id.slice(-length)) + ) + length += 1; + return `${workspace.name} · ${workspace.id.slice(-length)}`; +} + +/** Display only: never use a shortened path for filesystem operations. */ +export function formatWorkspacePath( + path: string, + format: AppearanceConfig["workspacePathFormat"], + maxLength = DEFAULT_PATH_TRUNCATE_LENGTH, +): string { + const value = path; + const limit = Math.max(0, Math.floor(maxLength)); + if (limit === 0) return ""; + if (value.length <= limit) return value; + if (limit === 1) return "…"; + if (format === "start") return `${pathTextEdge(value, limit - 1, "start")}…`; + if (format === "end") { + const separator = value.includes("/") ? "/" : "\\"; + const tail = pathTextEdge(value, limit - 1, "end"); + const boundary = tail.indexOf(separator); + return `…${boundary >= 0 && boundary < tail.length - 1 ? tail.slice(boundary) : tail}`; + } + return truncatePathMiddle(value, limit); +} + +export function workspaceSecondaryLabel( + workspace: Workspace, + preferences: WorkspacePathPreferences, +): string { + const branch = workspace.managedWorktree?.branch; + const path = + preferences.showWorkspacePaths && workspace.folderPath + ? formatWorkspacePath(workspace.folderPath, preferences.workspacePathFormat) + : undefined; + return ( + [branch, path].filter(Boolean).join(" · ") || + (workspace.folderPath ? "" : `No folder · ${workspace.id.slice(0, 8)}`) + ); +} diff --git a/renderer/main/bots-view.test.tsx b/renderer/main/bots-view.test.tsx index cbd8e441..312f1e5d 100644 --- a/renderer/main/bots-view.test.tsx +++ b/renderer/main/bots-view.test.tsx @@ -112,7 +112,7 @@ test("bot editor owns access mode, model selection, and capability toggles", () assert.match(view, /invalidateQueries\(\{ queryKey: queryKeys\.botAccess\(saved\.id\) \}\)/u); assert.match(view, /invalidateQueries\(\{ queryKey: queryKeys\.botCapabilityCatalog \}\)/u); assert.match(queries, /botCapabilityCatalog: \["bot-capability-catalog"\] as const/u); - assert.match(queries, /useBotCapabilityCatalog\(enabled: boolean\)/u); + assert.match(queries, /useBotCapabilityCatalog\(enabled: boolean, botId\?: string\)/u); assert.match(queries, /useBotAccess\(botId: string \| undefined\)/u); }); @@ -521,3 +521,16 @@ test("Remote Bot and chat notifications invalidate every dependent Bot cache", ( /onNotification\("bots:changed"[\s\S]*queryKey: queryKeys\.bots[\s\S]*\["bot"\][\s\S]*\["bot-chats"\][\s\S]*\["bot-telegram-binding"\][\s\S]*queryKeys\.botTelegramTargets/u, ); }); + + +test("Bot editor reads a target-scoped dormant catalog and retains disabled skill choices", () => { + const view = readFileSync(new URL("./bots-view.tsx", import.meta.url), "utf8"); + const ipc = readFileSync(new URL("../lib/ipc.ts", import.meta.url), "utf8"); + const handlers = readFileSync(new URL("../../main/handlers/bots.ts", import.meta.url), "utf8"); + assert.match(view, /useBotCapabilityCatalog\(true, committedBot\?\.id\)/u); + assert.match(view, /botsApi\.getCapabilityCatalog\(saved\.id\)/u); + assert.match(view, /option\.available \|\| catalog\.skillsEnabled === false/u); + assert.match(view, /Skills are off globally/u); + assert.match(ipc, /getCapabilityCatalog: \(botId\?: string\)/u); + assert.match(handlers, /id === undefined \? undefined : parseBotId\(id\)/u); +}); diff --git a/renderer/main/bots-view.tsx b/renderer/main/bots-view.tsx index 90c462b0..f31cba66 100644 --- a/renderer/main/bots-view.tsx +++ b/renderer/main/bots-view.tsx @@ -209,7 +209,11 @@ function buildBotAccessUpdate( }; assertAvailable(catalog.fileScopes, draft.fileScopeIds, "file access"); assertAvailable(catalog.connections, draft.connectionIds, "connection"); - assertAvailable(catalog.skills, draft.skillIds, "skill"); + assertAvailable( + catalog.skills.map((option) => ({ ...option, available: option.available || catalog.skillsEnabled === false })), + draft.skillIds, + "skill", + ); assertAvailable(catalog.otherCapabilities, draft.otherCapabilityIds, "capability"); if (draft.shellEnabled && !catalog.shellAvailable) { throw new Error("Run commands is not currently available on this Mac."); @@ -303,7 +307,7 @@ function BotEditor({ const [saving, setSaving] = React.useState(false); const [noticing, setNoticing] = React.useState(false); const savingRef = React.useRef(false); - const catalogQuery = useBotCapabilityCatalog(true); + const catalogQuery = useBotCapabilityCatalog(true, committedBot?.id); const accessQuery = useBotAccess(bot?.id); const catalog = catalogQuery.data; const [accessDraft, setAccessDraft] = React.useState(null); @@ -353,7 +357,7 @@ function BotEditor({ // and access either become visible together or are rolled back together. // Re-read the catalog so Custom grants bind against current opaque ids. const latestCatalog = await botsApi.getCapabilityCatalog(); - qc.setQueryData(queryKeys.botCapabilityCatalog, latestCatalog); + qc.setQueryData([...queryKeys.botCapabilityCatalog, undefined], latestCatalog); saved = await botsApi.create({ bot: createInputFromDraft(draft), access: buildBotAccessUpdate(accessDraft, latestCatalog), @@ -385,7 +389,7 @@ function BotEditor({ // catalog. Unrelated changes from iOS or another Mac surface survive. const [state, latestCatalog] = await Promise.all([ botsApi.getBotAccess(saved.id), - botsApi.getCapabilityCatalog(), + botsApi.getCapabilityCatalog(saved.id), ]); if (!state) throw new Error("This bot’s access policy could not be read."); const authoritativeAccess = accessDraftFromState(state, latestCatalog); @@ -396,7 +400,7 @@ function BotEditor({ ); setAccessDraft(rebasedAccess); setAccessBaseline(authoritativeAccess); - qc.setQueryData(queryKeys.botCapabilityCatalog, latestCatalog); + qc.setQueryData([...queryKeys.botCapabilityCatalog, saved.id], latestCatalog); const update = buildBotAccessUpdate(rebasedAccess, latestCatalog); if (botAccessDiffers(update, state)) { await botsApi.updateBotAccess({ @@ -920,7 +924,9 @@ function BotEditor({ {([ ["Connections", "Services and accounts this bot may use.", catalog.connections, "connectionIds"], - ["Skills", "Aiden skills this bot may use.", catalog.skills, "skillIds"], + ["Skills", catalog.skillsEnabled === false + ? "Skills are off globally. Saved choices are kept and will be checked again when Skills is enabled." + : "Aiden skills this bot may use.", catalog.skills, "skillIds"], ["Other capabilities", "Additional capabilities available on this Mac.", catalog.otherCapabilities, "otherCapabilityIds"], ] as const).map(([title, description, options, key]) => { // Match iOS: hide unusable, unselected tombstones (e.g. skills @@ -1089,7 +1095,7 @@ function Roster({ bots, onCreate }: { bots: BotDefinition[]; onCreate(): void }) function BotAccessSummary({ botId }: { botId: string }) { const accessQuery = useBotAccess(botId); - const catalogQuery = useBotCapabilityCatalog(true); + const catalogQuery = useBotCapabilityCatalog(true, botId); const state = accessQuery.data; const model = botModelLabel(catalogQuery.data, state); if (!state) return null; diff --git a/renderer/main/settings-view.tsx b/renderer/main/settings-view.tsx index 0db4f4d1..516656fb 100644 --- a/renderer/main/settings-view.tsx +++ b/renderer/main/settings-view.tsx @@ -20,7 +20,6 @@ import { Clock3, Send, Smartphone, - BrainCircuit, } from "lucide-react"; import { ProvidersSettings } from "../components/settings/providers-settings"; import { AppearanceSettings } from "../components/settings/appearance-settings"; @@ -36,6 +35,8 @@ import { AboutSettings } from "../components/settings/about-settings"; import { ScheduledTasksSettings } from "../components/settings/scheduled-tasks-settings"; import { AssistantSettings } from "../components/settings/assistant-settings"; import { RemoteAccessSettings } from "../components/settings/remote-access-settings"; +import { MemoryCardIcon } from "../components/memory-card-icon"; +import { SettingsPage } from "../components/settings/settings-page"; import { MemorySettings } from "../components/settings/memory-settings"; import { SETTINGS_DESTINATIONS, type SettingsSection } from "../lib/settings-section"; @@ -82,7 +83,7 @@ const NAV_ICONS: Record = { scheduledTasks: , assistant: , computerUse: , - memory: , + memory: , voice: , shortcut: , appearance: , @@ -115,6 +116,24 @@ const CONTENT: Record = { about: AboutSettings, }; +const DESCRIPTIONS: Record = { + providers: "Connect models to Aiden and manage the providers you use.", + modelData: "Arrange your models by capability and pace. Your map stays on this Mac.", + skills: "Choose the reusable instructions Aiden can load in chats.", + mcp: "Connect tools and services to extend what Aiden can do.", + telegram: "Connect your Telegram bots and choose how they respond.", + remoteAccess: "Pair your devices to use Aiden on the go.", + websearch: "Choose how Aiden searches and reads the web.", + computerUse: "Manage Aiden’s access to native apps and your screen.", + memory: "Control what Aiden remembers and how long chats stay manageable.", + scheduledTasks: "Manage when Aiden works in the background.", + assistant: "Choose how your Aiden companion works with you.", + voice: "Set up voice input, transcription, and dictation.", + shortcut: "Customize the keyboard controls for Aiden and the app.", + appearance: "Shape Aiden’s light and dark interfaces independently. Changes apply live.", + about: "App information, updates, and diagnostics.", +}; + export function SettingsView({ initialSection }: { initialSection?: SettingsSection }) { const router = useRouter(); const navigate = useNavigate(); @@ -191,7 +210,7 @@ export function SettingsView({ initialSection }: { initialSection?: SettingsSect replace: true, }) } - className={`flex min-h-10 w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left text-[15px] outline-none transition-[background-color,box-shadow] duration-150 ease-out hover:bg-list-hover active:bg-list-selection focus-visible:bg-list-selection focus-visible:outline-none ${ + className={`flex min-h-10 w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left text-[15px] outline-none transition-[background-color,box-shadow] duration-150 ease-out hover:bg-list-hover active:bg-list-selection focus-visible:bg-list-selection focus-visible:ring-2 focus-visible:ring-focus-ring ${ selected ? "bg-list-selection text-primary hover:bg-list-selection" : "text-primary" @@ -218,8 +237,10 @@ export function SettingsView({ initialSection }: { initialSection?: SettingsSect } > -
- +
+ item.id === section)?.title ?? "Settings"} description={DESCRIPTIONS[section]}> + +
diff --git a/renderer/shared/appearance.ts b/renderer/shared/appearance.ts index 0b6a3255..58b4e19d 100644 --- a/renderer/shared/appearance.ts +++ b/renderer/shared/appearance.ts @@ -25,6 +25,8 @@ export interface AppearanceConfig { light: ThemeVariantConfig; dark: ThemeVariantConfig; pointerCursors: boolean; + showWorkspacePaths: boolean; + workspacePathFormat: "middle" | "end" | "start"; dockIcon: DockIconPreference; reduceMotion: ReduceMotionPreference; uiFontSize: number; @@ -211,6 +213,8 @@ const DEFAULT_APPEARANCE: AppearanceConfig = { light: getPresetVariant("aiden", "light"), dark: getPresetVariant("aiden", "dark"), pointerCursors: false, + showWorkspacePaths: false, + workspacePathFormat: "middle", dockIcon: "aiden", reduceMotion: "system", uiFontSize: 14, @@ -294,6 +298,12 @@ export function normalizeAppearanceConfig(value: unknown): AppearanceConfig { pointerCursors: typeof value.pointerCursors === "boolean" ? value.pointerCursors : fallback.pointerCursors, + showWorkspacePaths: typeof value.showWorkspacePaths === "boolean" + ? value.showWorkspacePaths + : fallback.showWorkspacePaths, + workspacePathFormat: value.workspacePathFormat === "middle" || value.workspacePathFormat === "end" || value.workspacePathFormat === "start" + ? value.workspacePathFormat + : fallback.workspacePathFormat, dockIcon: value.dockIcon === "monochrome" || value.dockIcon === "aiden" ? value.dockIcon : fallback.dockIcon, @@ -336,6 +346,13 @@ export function parseAppearanceConfig(value: unknown): AppearanceConfig { throw new Error("Appearance settings are incomplete."); } const normalized = normalizeAppearanceConfig(value); + // Optional for older v1 exports; reject malformed explicitly supplied preferences. + if (value.showWorkspacePaths !== undefined && typeof value.showWorkspacePaths !== "boolean") { + throw new Error("Workspace path visibility must be a boolean value."); + } + if (value.workspacePathFormat !== undefined && value.workspacePathFormat !== normalized.workspacePathFormat) { + throw new Error("Workspace path format is unsupported."); + } const verifyVariant = (variant: unknown, label: string) => { if (!isRecord(variant)) throw new Error(`${label} theme must be an object.`); for (const key of ["accent", "background", "foreground"]) { diff --git a/renderer/shared/bot-capabilities.test.ts b/renderer/shared/bot-capabilities.test.ts index 99a5e895..65cb64f2 100644 --- a/renderer/shared/bot-capabilities.test.ts +++ b/renderer/shared/bot-capabilities.test.ts @@ -2,11 +2,14 @@ import assert from "node:assert/strict"; import test from "node:test"; import { botCustomSelectionIsSubset, + BOT_FULL_ACCESS_NOTICE_VERSION, BOT_FILE_SCOPE_SELECTION_GUIDANCE, botFileScopeSelectionIsCoherent, intersectBotCustomSelections, nextBotFileScopeIds, parseBotAccessUpdate, + validateSelectionAgainstCatalog, + type BotCapabilityCatalog, type BotCustomSelection, type BotFileScopeOption, } from "./bot-capabilities.js"; @@ -128,3 +131,22 @@ test("file-scope intersection preserves a chat reduction below Full Mac", () => ["home"], ); }); + + +test("disabled catalog preserves only saved skill grants and leaves every other capability check strict", () => { + const catalog: BotCapabilityCatalog = { + revision: "catalog:paused", providers: [{ id: "provider", label: "Provider", available: true, + models: [{ id: "model", label: "Model", available: true }] }], + fileScopes: scopes, shellAvailable: true, connections: [], + skills: [{ id: "saved", label: "Saved skill", available: false }, { id: "other", label: "Other skill", available: true }], + skillsEnabled: false, otherCapabilities: [], + notice: { version: BOT_FULL_ACCESS_NOTICE_VERSION, requiresAcknowledgement: true }, + }; + const chosen = { ...selection(["home"]), skillIds: ["saved"] }; + const retainedSkillIds = ["saved"]; + assert.doesNotThrow(() => validateSelectionAgainstCatalog(chosen, catalog, { retainedSkillIds })); + assert.throws(() => validateSelectionAgainstCatalog(chosen, catalog), /disabled/u); + assert.throws(() => validateSelectionAgainstCatalog({ ...chosen, skillIds: ["other"] }, catalog, { retainedSkillIds }), /disabled/u); + assert.throws(() => validateSelectionAgainstCatalog({ ...chosen, connectionIds: ["unknown"] }, catalog, { retainedSkillIds }), /connection/u); + assert.throws(() => validateSelectionAgainstCatalog(chosen, { ...catalog, skillsEnabled: true }, { retainedSkillIds }), /skill/u); +}); diff --git a/renderer/shared/bot-capabilities.ts b/renderer/shared/bot-capabilities.ts index 7e966e4f..94676b69 100644 --- a/renderer/shared/bot-capabilities.ts +++ b/renderer/shared/bot-capabilities.ts @@ -91,6 +91,8 @@ export interface BotCapabilityCatalog { shellAvailable: boolean; connections: BotCapabilityOption[]; skills: BotCapabilityOption[]; + /** Omitted means enabled. False suppresses execution while saved grants remain dormant. */ + skillsEnabled?: boolean; otherCapabilities: BotCapabilityOption[]; notice: BotNoticeStatus; } @@ -572,7 +574,7 @@ export function intersectBotCustomSelections( export function validateSelectionAgainstCatalog( selection: BotCustomSelection, catalog: BotCapabilityCatalog, - options: { requireAvailable?: boolean } = {}, + options: { requireAvailable?: boolean; retainedSkillIds?: readonly string[] } = {}, ): void { const requireAvailable = options.requireAvailable ?? true; const requireOptions = ( @@ -601,7 +603,14 @@ export function validateSelectionAgainstCatalog( } requireOptions(selection.fileScopeIds, catalog.fileScopes, "file scope"); requireOptions(selection.connectionIds, catalog.connections, "connection"); - requireOptions(selection.skillIds, catalog.skills, "skill"); + const skillsToValidate = catalog.skillsEnabled === false + ? selection.skillIds.filter((id) => !options.retainedSkillIds?.includes(id)) + : selection.skillIds; + // Even a malformed disabled catalog cannot authorize a new skill grant. + if (catalog.skillsEnabled === false && skillsToValidate.length > 0) { + throw new BotCapabilityValidationError("Skills are disabled. Only saved skill grants may be retained."); + } + requireOptions(skillsToValidate, catalog.skills, "skill"); requireOptions(selection.otherCapabilityIds, catalog.otherCapabilities, "capability"); } diff --git a/renderer/shared/settings-section.ts b/renderer/shared/settings-section.ts index 22067d0a..2965e2aa 100644 --- a/renderer/shared/settings-section.ts +++ b/renderer/shared/settings-section.ts @@ -126,7 +126,7 @@ export const SETTINGS_DESTINATIONS: ReadonlyArray<{ id: "appearance", title: "Appearance", group: "App", - keywords: ["theme", "light", "dark"], + keywords: ["theme", "light", "dark", "workspace", "folder", "paths", "truncation"], }, { id: "about", diff --git a/renderer/styles.css b/renderer/styles.css index 7fca3a79..ef3c7c1f 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -529,9 +529,8 @@ textarea { * the current model instead of turning the map into an unlabeled mood field. */ .model-pad-fieldset { - --model-pad-fieldset-width: max(100%, min(64rem, calc(100vw - 19rem))); - width: var(--model-pad-fieldset-width); - margin-inline: calc((100% - var(--model-pad-fieldset-width)) / 2); + width: 100%; + min-width: 0; container: model-pad-fieldset / inline-size; } @@ -551,7 +550,7 @@ textarea { } .settings-model-pad-grid[data-panel-open="true"] { - grid-template-columns: minmax(28rem, 40rem) minmax(16rem, 18rem); + grid-template-columns: minmax(0, 1fr) minmax(16rem, 18rem); } .model-pad-canvas { @@ -560,10 +559,6 @@ textarea { view-transition-name: model-pad-canvas; } -.settings-model-pad-grid[data-panel-open="true"] .model-pad-canvas { - width: min(100%, 40rem); -} - .model-pad-browser { position: sticky; top: 1rem; @@ -593,6 +588,8 @@ textarea { } .model-pad-axis-label { + width: min(100%, var(--model-pad-available-size, max(10rem, calc(100dvh - 24rem)))); + margin-inline: auto; color: var(--text-secondary); font-size: var(--text-strong); font-weight: 600; @@ -601,6 +598,8 @@ textarea { } .model-pad { + width: min(100%, var(--model-pad-available-size, max(10rem, calc(100dvh - 24rem)))); + margin-inline: auto; cursor: crosshair; contain: layout paint; background: @@ -780,7 +779,7 @@ textarea { } .model-pad-catalog { - max-height: min(48rem, calc(100vh - 12rem)); + max-height: max(8rem, var(--model-pad-available-size, calc(100dvh - 24rem))); } .model-pad-catalog-shell { @@ -955,6 +954,107 @@ textarea { .settings-responsive { container: settings-content / inline-size; + --settings-card-radius: 16px; + --settings-card-fill: color-mix(in srgb, var(--surface-popover) 72%, transparent); + --settings-row-inset: 16px; + --settings-row-gap: 18px; +} + +/* All Settings destinations share Appearance's page, group, and row language. */ +.settings-page { + min-width: 0; + padding-bottom: 28px; +} + +.settings-page-heading { + margin: 0 4px 26px; +} + +.settings-page-heading h1 { + margin: 0; + color: var(--text-primary); + font-size: 26px; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 32px; +} + +.settings-page-heading p { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: var(--text-small); + line-height: 1.5; +} + +.settings-page .settings-group-title { + margin: 0 4px 12px; + padding: 0; + font-size: var(--text-strong); + font-weight: 600; +} + +.settings-page .settings-card, +.settings-page .settings-group-card { + border: 1px solid var(--border-separator); + border-radius: var(--settings-card-radius); + background: var(--settings-card-fill); + box-shadow: var(--elevation-control); +} + +.settings-page .settings-field { + min-width: 0; + min-height: 64px; + padding: 12px var(--settings-row-inset); + gap: var(--settings-row-gap); +} + +.settings-page .settings-field-horizontal { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +.settings-page .settings-field-control > * { + max-width: 100%; +} + +/* Compact controls stay at the trailing edge, even when the row wraps. */ +.settings-page .settings-field-control > button, +.settings-page .settings-field-control > [role="switch"] { + display: flex; + margin-left: auto; +} + + +.settings-page .settings-field-horizontal:has(> .settings-field-control > [role="switch"]) { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.settings-page .settings-field-control > .flex { + flex-wrap: wrap; +} + +.settings-page .settings-field-control input { + min-width: 0; +} + +.settings-page .settings-field:has(> .settings-field-control:empty) { + grid-template-columns: minmax(0, 1fr); +} + +.settings-page :is(button, [role="button"], summary):focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +@container settings-content (max-width: 540px) { + .settings-page .settings-field-horizontal { + grid-template-columns: minmax(0, 1fr); + gap: 8px; + } + + .settings-page-heading.flex { + flex-wrap: wrap; + } } @container composer (max-width: 520px) { @@ -1808,8 +1908,8 @@ textarea { .appearance-preferences-card { overflow: hidden; border: 1px solid var(--border-separator); - border-radius: 16px; - background: color-mix(in srgb, var(--surface-popover) 72%, transparent); + border-radius: var(--settings-card-radius, 16px); + background: var(--settings-card-fill, color-mix(in srgb, var(--surface-popover) 72%, transparent)); box-shadow: 0 1px 2px rgb(0 0 0 / 0.025); } diff --git a/scripts/check-ios-shipping-target.test.mjs b/scripts/check-ios-shipping-target.test.mjs index 04bb3fb6..9d5edb81 100644 --- a/scripts/check-ios-shipping-target.test.mjs +++ b/scripts/check-ios-shipping-target.test.mjs @@ -1129,3 +1129,35 @@ test("the shipping app icon is the reviewed opaque RayChat artwork", async () => "bb4c7fdd6f5597e415348823902e606bba75098a12289c15a3e434df7619fb6c", ); }); + +test("Bot catalog requests stay scoped through editor, selection, chat, files, and offline cache", async () => { + const read = (path) => readFile(new URL(`../ios/${path}`, import.meta.url), "utf8"); + const [client, editor, custom, chat, cache, clientTests, cacheTests] = await Promise.all([ + read("AidenOnTheGo/Networking/AidenRemoteClient.swift"), + read("AidenOnTheGo/Features/Bots/AidenBotEditorView.swift"), + read("AidenOnTheGo/Features/Bots/AidenBotCustomAccessFlowView.swift"), + read("AidenOnTheGo/Features/Remote/AidenBotChatToolsView.swift"), + read("AidenOnTheGo/Persistence/AidenBotCache.swift"), + read("AidenOnTheGoTests/AidenRemoteClientTests.swift"), + read("AidenOnTheGoTests/AidenBotCacheTests.swift"), + ]); + assert.match(client, /func botCapabilityCatalog\(botId: String\? = nil\)[\s\S]*?validateBotIdentifier\(botId\)[\s\S]*?URLQueryItem\(name: "botId", value: \$0\)/u); + assert.equal((editor.match(/botCapabilityCatalog\(\)/gu) ?? []).length, 1, "only new-Bot creation uses generic inventory"); + assert.match(editor, /case \.create:[\s\S]*?client\.botCapabilityCatalog\(\)/u); + assert.match(editor, /botCapabilityCatalog\(botId: botID\)/u); + assert.match(editor, /botCapabilityCatalog\(botId: attempt\.botID\)/u); + for (const source of [custom, chat]) assert.doesNotMatch(source, /botCapabilityCatalog\(\)/u); + assert.match(custom, /loadSelectedBot[\s\S]*?botCapabilityCatalog\(botId: request\.botID\)/u); + assert.match(custom, /selectedBot\?\.id == selectedBotID/u); + assert.match(chat, /botCapabilityCatalog\(botId: botID\)/u); + assert.equal((chat.match(/botCapabilityCatalog\(botId: grant\.botID\)/gu) ?? []).length, 2, "file load and pre-effect revalidation use the grant owner"); + assert.match(editor, /cached\.catalog\(forBotID: mode\.catalogBotID\)/u); + assert.match(custom, /cached\.catalog\(forBotID: selectedBotID\)/u); + assert.match(chat, /cached\.catalog\(forBotID: botID\)/u); + assert.match(cache, /if let botID \{ return catalogsByBotID\?\[botID\] \}/u); + assert.match(cache, /maximumEnvelopeBytes = 4 \* 1_024 \* 1_024/u); + assert.match(clientTests, /testBotCatalogRequestsUseExactTargetAndKeepLegacyCreateGeneric/u); + assert.match(clientTests, /testBotCatalogRejectsInvalidTargetsBeforeIssuingAnyRequest/u); + assert.match(cacheTests, /testTargetedCatalogsNeverOverwriteOrFallbackToGlobalOrAnotherBot/u); + assert.match(cacheTests, /testLegacyCacheDecodesWithoutScopedCatalogsAndListRefreshPrunesDeletedBotScopes/u); +}); diff --git a/tests/e2e/chat-shell-interactions.spec.ts b/tests/e2e/chat-shell-interactions.spec.ts index 02272327..2587909e 100644 --- a/tests/e2e/chat-shell-interactions.spec.ts +++ b/tests/e2e/chat-shell-interactions.spec.ts @@ -247,7 +247,7 @@ test.describe("with a workspace", () => { await expect(sidebarResizer).toHaveAttribute("aria-valuenow", "340"); const workspaceActions = page.getByRole("button", { - name: /^Actions for Aiden E2E workspace,/u, + name: "Actions for Aiden E2E workspace", exact: true, }); await workspaceActions.evaluate((node) => node.scrollIntoView({ block: "end" })); const triggerBounds = await workspaceActions.boundingBox(); diff --git a/tests/e2e/model-pad-responsive.spec.ts b/tests/e2e/model-pad-responsive.spec.ts new file mode 100644 index 00000000..7d864dda --- /dev/null +++ b/tests/e2e/model-pad-responsive.spec.ts @@ -0,0 +1,154 @@ +import { E2E_MODEL_DISPLAY_NAME, expect, finishLmStudioOnboarding, test } from "./fixtures"; + +type PadReachability = { + fits: boolean; + pad: { top: number; bottom: number; height: number }; + scrollport: { + top: number; + bottom: number; + clientHeight: number; + scrollHeight: number; + scrollTop: number; + } | null; + viewport: { width: number; height: number }; +}; + +// Measure rendered geometry in Electron, including both navigation columns and +// native zoom; CSS/source assertions cannot catch a square extending offscreen. +test("Model Pad fits resized settings and keeps models usable at native zoom", async ({ + aiden, +}) => { + const { page, app } = aiden; + await finishLmStudioOnboarding(page); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: "Model Pad", exact: true }) + .click(); + const pad = page.getByRole("group", { name: "Personal Model Pad arrangement", exact: true }); + const browse = page.getByRole("button", { name: "Browse models", exact: true }); + const insights = page.getByRole("button", { name: "Benchmark insights", exact: true }); + + for (const [width, height, zoom] of [ + [1440, 1000, 1], + [1280, 720, 1], + [900, 600, 1], + [1280, 800, 1.25], + [900, 456, 1], + [1280, 720, 1.5], + [600, 600, 1], + [390, 456, 1], + ]) { + await app.evaluate( + ({ BrowserWindow }, size) => { + const window = BrowserWindow.getAllWindows()[0]; + window.setSize(size.width, size.height); + window.webContents.setZoomFactor(size.zoom); + }, + { width, height, zoom }, + ); + + for (const panel of ["closed", "models", "insights"] as const) { + if (panel === "models") await browse.click(); + if (panel === "insights") await insights.click(); + await page.locator(".model-pad-fieldset").evaluate((element) => { + let parent = element.parentElement; + while (parent && !/(auto|scroll)/u.test(getComputedStyle(parent).overflowY)) + parent = parent.parentElement; + if (parent) parent.scrollTop = 0; + }); + await expect + .poll( + async () => + pad.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + const canvas = element.parentElement!; + const grid = canvas.parentElement!; + return { + square: Math.abs(bounds.width - bounds.height) <= 1, + fitsWidth: bounds.left >= 0 && bounds.right <= innerWidth, + fitsHeight: bounds.height <= innerHeight, + fitsContainer: bounds.width <= grid.clientWidth + 1, + // On the smallest windows the surrounding settings controls scroll. + // At normal sizes the entire canvas + labels must be in the viewport. + fitsViewport: + innerWidth < 800 || + innerHeight < 560 || + canvas.getBoundingClientRect().bottom <= innerHeight, + }; + }), + { message: `${width}×${height}, ${zoom} zoom, ${panel}` }, + ) + .toEqual({ + square: true, + fitsWidth: true, + fitsHeight: true, + fitsContainer: true, + fitsViewport: true, + }); + // Small/zoomed windows scroll their chrome; the entire Pad remains reachable. + await expect + .poll( + () => + pad.evaluate(async (element): Promise => { + // A sidebar collapse or supporting-panel transition can finish after + // the first scroll. Reapply the user's scroll after layout settles, + // then sample on the following frame. + element.scrollIntoView({ block: "center", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + const bounds = element.getBoundingClientRect(); + let scrollport = element.parentElement; + while (scrollport && !/(auto|scroll)/u.test(getComputedStyle(scrollport).overflowY)) { + scrollport = scrollport.parentElement; + } + const scrollportBounds = scrollport?.getBoundingClientRect(); + const visibleTop = Math.max(0, scrollportBounds?.top ?? 0); + const visibleBottom = Math.min(innerHeight, scrollportBounds?.bottom ?? innerHeight); + return { + fits: bounds.top >= visibleTop && bounds.bottom <= visibleBottom, + pad: { + top: Math.round(bounds.top), + bottom: Math.round(bounds.bottom), + height: Math.round(bounds.height), + }, + scrollport: scrollportBounds + ? { + top: Math.round(scrollportBounds.top), + bottom: Math.round(scrollportBounds.bottom), + clientHeight: scrollport!.clientHeight, + scrollHeight: scrollport!.scrollHeight, + scrollTop: scrollport!.scrollTop, + } + : null, + viewport: { width: innerWidth, height: innerHeight }, + }; + }), + { message: `reachable Pad at ${width}×${height}, ${zoom} zoom, ${panel}` }, + ) + .toMatchObject({ fits: true }); + const legend = page.locator(".model-pad-legend"); + await legend.scrollIntoViewIfNeeded(); + await expect(legend).toBeInViewport(); + if (panel === "insights") await insights.click(); + } + } + + await app.evaluate(({ BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0]; + window.setSize(1280, 800); + window.webContents.setZoomFactor(1); + }); + await browse.click(); + await page + .getByRole("button", { name: new RegExp(`^Add ${E2E_MODEL_DISPLAY_NAME} .* to Pad$`, "u") }) + .click(); + const marker = pad.getByRole("button", { + name: new RegExp(`^${E2E_MODEL_DISPLAY_NAME} from`, "u"), + }); + await marker.focus(); + const before = await marker.getAttribute("style"); + await marker.press("ArrowRight"); + await expect(marker).not.toHaveAttribute("style", before!); + await page.getByRole("button", { name: "Save Pad", exact: true }).click(); + await expect(page.getByText("Saved locally", { exact: true })).toBeVisible(); +}); diff --git a/tests/e2e/settings-model-picker.spec.ts b/tests/e2e/settings-model-picker.spec.ts index 28d4394f..520e102a 100644 --- a/tests/e2e/settings-model-picker.spec.ts +++ b/tests/e2e/settings-model-picker.spec.ts @@ -30,7 +30,7 @@ async function assertRenderedSettingsDestination( return; case "Model Pad": await expect( - page.getByRole("heading", { level: 2, name: "Personal Model Pad", exact: true }), + page.getByRole("heading", { level: 1, name: "Model Pad", exact: true }), ).toBeVisible(); return; case "Skills": @@ -50,7 +50,7 @@ async function assertRenderedSettingsDestination( return; case "Remote Access": await expect( - page.getByRole("heading", { level: 2, name: "Remote Access", exact: true }), + page.getByRole("heading", { level: 1, name: "Remote Access", exact: true }), ).toBeVisible(); await expect( page.getByRole("switch", { name: "Enable Aiden Remote Access" }), @@ -64,7 +64,7 @@ async function assertRenderedSettingsDestination( return; case "Scheduled tasks": await expect( - page.getByRole("heading", { level: 2, name: "Scheduled tasks", exact: true }), + page.getByRole("heading", { level: 1, name: "Scheduled tasks", exact: true }), ).toBeVisible(); return; case "Aiden": @@ -73,7 +73,7 @@ async function assertRenderedSettingsDestination( ).toBeVisible(); return; case "Computer Use": - await expect(page.getByRole("heading", { level: 2, name: /^Computer Use/u })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "Computer Use", exact: true })).toBeVisible(); return; case "Voice": await expect( @@ -92,7 +92,7 @@ async function assertRenderedSettingsDestination( return; case "About": await expect( - page.getByRole("heading", { level: 2, name: "About", exact: true }), + page.getByRole("heading", { level: 1, name: "About", exact: true }), ).toBeVisible(); } } diff --git a/tests/e2e/settings-unification.spec.ts b/tests/e2e/settings-unification.spec.ts new file mode 100644 index 00000000..9763ca03 --- /dev/null +++ b/tests/e2e/settings-unification.spec.ts @@ -0,0 +1,174 @@ +import { expect, finishLmStudioOnboarding, test } from "./fixtures"; + +test.use({ workspaceSeed: true }); + +test("disabling Skills removes hidden instructions from the next provider request", async ({ + aiden, +}) => { + const { page } = aiden; + await finishLmStudioOnboarding(page); + await page.evaluate(async () => { + const { ipc } = ( + window as unknown as { + aidenAPI: { ipc: { invoke(channel: string, ...args: unknown[]): Promise } }; + } + ).aidenAPI; + await ipc.invoke("skills:save", { + id: "skills-off-fixture", + name: "Review fixture", + description: "A deterministic review skill", + instructions: "PRIVATE_SKILL_INSTRUCTION_MARKER: Review the supplied code.", + enabled: true, + }); + }); + await page.reload(); + const composer = page.locator("textarea"); + await composer.fill("$"); + await page + .getByRole("listbox", { name: "Skills" }) + .getByRole("option", { name: /Review fixture/u }) + .click(); + await composer.fill("Review the fixture code."); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(page.getByRole("button", { name: "Copy message" })).toHaveCount(2); + expect( + aiden.lmStudio.requests.some((request) => + JSON.stringify(request.body).includes("PRIVATE_SKILL_INSTRUCTION_MARKER"), + ), + ).toBe(true); + + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: "Skills", exact: true }) + .click(); + await page.getByRole("switch", { name: "Use skills globally" }).click(); + await expect(page.getByRole("switch", { name: "Use skills globally" })).not.toBeChecked(); + await page.getByRole("button", { name: "Back to app", exact: true }).click(); + await composer.fill("Continue after disabling skills."); + await page.getByRole("button", { name: "Send message" }).click(); + await expect(page.getByRole("button", { name: "Copy message" })).toHaveCount(4); + const next = [...aiden.lmStudio.requests].reverse().find((request) => { + const body = request.body as { stream?: boolean }; + return ( + body?.stream === true && JSON.stringify(body).includes("Continue after disabling skills.") + ); + }); + expect(next).toBeDefined(); + expect(JSON.stringify(next!.body)).not.toContain("PRIVATE_SKILL_INSTRUCTION_MARKER"); + await composer.fill("$"); + await expect(page.getByRole("listbox", { name: "Skills" }).getByRole("option")).toHaveCount(0); +}); + +test("workspace paths default hidden, change live, and survive relaunch", async ({ aiden }) => { + let page = aiden.page; + await finishLmStudioOnboarding(page); + const workspaceName = "Aiden E2E workspace"; + const workspaceRow = () => + page.getByRole("button", { name: new RegExp(`^(Expand|Collapse) ${workspaceName}`, "u") }); + await expect(workspaceRow()).toHaveText(workspaceName); + + const openAppearance = async () => { + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: "Appearance", exact: true }) + .click(); + }; + await openAppearance(); + const showPaths = () => page.getByRole("switch", { name: "Show workspace folder paths" }); + const format = () => page.getByRole("combobox", { name: "Workspace path format" }); + await expect(showPaths()).not.toBeChecked(); + await expect(format()).toBeDisabled(); + await showPaths().click(); + await format().click(); + await page.getByRole("option", { name: /Last folders/u }).click(); + // Wait for main-process persistence, not merely the optimistic preview. + await expect + .poll(() => + page.evaluate(async () => { + const { ipc } = ( + window as unknown as { + aidenAPI: { + ipc: { + invoke(channel: string): Promise<{ + appearance: { showWorkspacePaths: boolean; workspacePathFormat: string }; + }>; + }; + }; + } + ).aidenAPI; + return (await ipc.invoke("settings:get")).appearance; + }), + ) + .toMatchObject({ showWorkspacePaths: true, workspacePathFormat: "end" }); + await page.getByRole("button", { name: "Back to app", exact: true }).click(); + await expect(workspaceRow()).toContainText("…/"); + + page = await aiden.relaunch(); + await expect(workspaceRow()).toContainText("…/"); + await openAppearance(); + await expect(showPaths()).toBeChecked(); + await expect(format()).toContainText("Last folders"); + await showPaths().click(); + await page.getByRole("button", { name: "Back to app", exact: true }).click(); + await expect(workspaceRow()).toHaveText(workspaceName); +}); + +test("all Settings pages fit narrow and wide windows; Telegram toggles stay on the right", async ({ + aiden, +}) => { + test.setTimeout(180_000); + const { page, app } = aiden; + await finishLmStudioOnboarding(page); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + const navigation = page.getByRole("navigation", { name: "Settings" }); + const destinations = await navigation.getByRole("button").allTextContents(); + for (const destination of destinations) { + // Navigate with the sidebar exposed, then test the compact content allocation. + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1280, 800)); + const showSidebar = page.getByRole("button", { name: "Show sidebar", exact: true }); + if (await showSidebar.isVisible()) await showSidebar.click(); + await navigation.getByRole("button", { name: destination.trim(), exact: true }).click(); + await expect(page.getByRole("heading", { name: destination.trim(), exact: true })).toHaveCount( + 1, + ); + for (const width of [1280, 600, 390]) { + await app.evaluate( + ({ BrowserWindow }, size) => BrowserWindow.getAllWindows()[0].setSize(size, 650), + width, + ); + await expect + .configure({ soft: true }) + .poll( + () => + page.locator(".settings-responsive").evaluate((element) => { + const page = element.querySelector(".settings-page")!; + return Math.max( + element.scrollWidth - element.clientWidth, + page.scrollWidth - page.clientWidth, + ); + }), + { message: `${destination} at ${width}px` }, + ) + .toBeLessThanOrEqual(2); + if (destination.trim() === "Telegram") { + for (const name of [ + "Enable Telegram bridge", + "Live answer drafts", + "Private-chat threads", + ]) { + const toggle = page.getByRole("switch", { name, exact: true }); + await toggle.scrollIntoViewIfNeeded(); + expect( + await toggle.evaluate((element) => { + const row = element.closest('[role="group"]')!; + const label = row.firstElementChild!; + return element.getBoundingClientRect().left >= label.getBoundingClientRect().right; + }), + ).toBe(true); + } + } + } + } +});