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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .papercuts/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -469,6 +470,22 @@ data class AidenBotProviderOption(
}
}

object AidenBotSkillsEnabledSerializer : KSerializer<Boolean> {
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,
Expand All @@ -478,7 +495,9 @@ data class AidenBotCapabilityCatalog(
val connections: List<AidenBotCapabilityOption>,
val skills: List<AidenBotCapabilityOption>,
val otherCapabilities: List<AidenBotCapabilityOption>,
val notice: AidenBotNoticeStatus
val notice: AidenBotNoticeStatus,
@Serializable(with = AidenBotSkillsEnabledSerializer::class)
val skillsEnabled: Boolean = true
) {
init {
AidenBotWire.validateString(revision, "revision", AidenRemoteProtocol.MAX_IDENTIFIER_LENGTH)
Expand Down Expand Up @@ -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) &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<AidenBotCapabilityCatalog>(legacy.toString()).skillsEnabled)
val disabled = JsonObject(fields + ("skillsEnabled" to JsonPrimitive(false)))
assertFalse(json.decodeFromString<AidenBotCapabilityCatalog>(disabled.toString()).skillsEnabled)
for (invalid in listOf(JsonNull, JsonPrimitive("false"), JsonPrimitive(0))) {
assertThrows(Exception::class.java) {
json.decodeFromString<AidenBotCapabilityCatalog>(JsonObject(fields + ("skillsEnabled" to invalid)).toString())
}
}
}

@Test
fun testBotCustomSelectionSubsetRules() {
val ceiling = AidenBotCustomSelection(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AidenBotCapabilityCatalog>(
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
Expand Down
1 change: 1 addition & 0 deletions android/app/src/test/resources/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@
}
},
"botCapabilityCatalog": {
"skillsEnabled": true,
"revision": "bot_catalog_revision_3",
"providers": [
{
Expand Down
Loading
Loading