docs/backend-migration/plans/2026-04-23-assistant-user-data-migration-plan.md
For teammates: This plan runs in team mode — coordinator + multiple parallel teammates (NOT subagent-driven-development). Each teammate owns a numbered Task and works on their own branch. Steps use checkbox (
- [ ]) syntax for tracking.Companion specs:
AionUi/docs/backend-migration/specs/2026-04-23-assistant-user-data-migration-design.mdaionui-backend/docs/backend-migration/specs/2026-04-23-assistant-user-data-migration-design.mdReference plans (pattern reuse):
2026-04-22-skill-library-pilot-plan.md— team coordination patterns2026-04-23-assistant-module-verification-plan.md— hand-off discipline
Goal: Move user-authored assistant definitions from Electron's
ConfigStorage.get('assistants') into the Rust backend as the single source
of truth, with a new aionui-assistant crate, backward-compatible rule-md
dispatch, and a one-shot first-launch migration.
Architecture: New domain crate aionui-assistant follows aionui-system's
strongly-typed-service pattern. Built-in assistants load from
{backend_exe}/assets/builtin-assistants/ at startup (no DB seed). User
assistants live in a new SQLite assistants table; per-assistant user state
(enabled/sort_order) in assistant_overrides. Merge of
built-in + user + extension happens server-side in AssistantService::list().
Tech Stack: Rust 2024 + axum 0.8 + sqlx 0.8 + serde (backend); TypeScript + React + Electron + Vitest + Playwright (frontend). Two coordinated branches, one per repo.
Team size: 1 coordinator + 4 role-teammates (backend-dev, backend-tester, frontend-dev, frontend-tester, e2e-tester — 5 teammates, 6 with coordinator).
| Branch | Repo | Base | Owner(s) |
|---|---|---|---|
feat/backend-migration-coordinator | AionUi | (reused from earlier pilots) | coordinator |
feat/backend-migration-assistant-user-data | AionUi | origin/feat/backend-migration-coordinator | frontend-dev, frontend-tester, e2e-tester |
feat/assistant-user-data | aionui-backend | origin/archive/skill-library-pilot-2026-04-23 | backend-dev, backend-tester |
T0 (coordinator setup)
│
▼
T1a (backend-dev: crate scaffolding + HTTP contract types + migration file)
│
├──► T1b (backend-dev: service + routes + tests)──► T2 (backend-tester)
│
└──► T3a (frontend-dev: TS types + ipcBridge + hooks rewrite)
│
└──► T3b (frontend-dev: main-process migration hook)
│
└──► T4 (frontend-tester: Vitest + lint + tsc)
│
T2 + T4 ────────┴──► T5 (e2e-tester: Playwright)
│
└──► T6 (coordinator closure + merge)
Parallelization: T1b and (T3a → T3b → T4) run in parallel after T1a. T2 depends only on T1b. T5 waits for both T2 and T4.
Critical path: T0 → T1a → T1b → T2 → T5 → T6.
Owner: coordinator. Depends on: nothing.
git -C /Users/zhoukai/Documents/github/AionUi fetch origin
git -C /Users/zhoukai/Documents/github/aionui-backend fetch origin
cd /Users/zhoukai/Documents/github/AionUi
git checkout -b feat/backend-migration-assistant-user-data origin/feat/backend-migration
git push -u origin feat/backend-migration-assistant-user-data
Expected: branch exists on remote at origin/feat/backend-migration tip.
cd /Users/zhoukai/Documents/github/aionui-backend
git checkout -b feat/assistant-user-data origin/main
git push -u origin feat/assistant-user-data
Run:
ls /Users/zhoukai/Documents/github/aionui-backend/crates/aionui-db/migrations/ | sort
Note the highest number prefix (e.g. 002_...sql). The new migration
created in T1a.2 uses the next number.
Via TeamCreate:
TeamCreate { team_name: "aionui-assistant-migration",
description: "Migrate user-authored assistants from Electron config to backend DB" }
Register tasks with owners:
Set addBlockedBy:
Run:
cd /Users/zhoukai/Documents/github/AionUi
git checkout feat/backend-migration-coordinator
git merge origin/feat/backend-migration --no-edit
git add docs/backend-migration/specs/2026-04-23-assistant-user-data-migration-design.md
git add docs/backend-migration/plans/2026-04-23-assistant-user-data-migration-plan.md
git commit -m "docs(backend-migration): add assistant user-data migration spec and plan"
git push
Coordinator keeps polling TaskList; when 1a is in_progress, move on.
Owner: backend-dev. Depends on: T0.
Goal: Produce the shared HTTP contract (Rust types + route skeleton + migration file + empty crate shell) so T3a can start in parallel.
Branch: feat/assistant-user-data (aionui-backend).
Pre-activation pulses:
"backend-dev alive on T1a"git rev-parse --abbrev-ref HEAD → confirm feat/assistant-user-dataaionui-assistant crate to workspace Create /Users/zhoukai/Documents/github/aionui-backend/crates/aionui-assistant/Cargo.toml:
[package]
name = "aionui-assistant"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
aionui-common = { workspace = true }
aionui-api-types = { workspace = true }
aionui-db = { workspace = true }
aionui-auth = { workspace = true }
aionui-extension = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
aionui-db = { workspace = true }
tempfile = { workspace = true }
tower = { workspace = true }
http-body-util = { workspace = true }
Create crates/aionui-assistant/src/lib.rs:
//! User-authored assistant management.
//!
//! Owns the `assistants` and `assistant_overrides` tables, built-in
//! assistant loading from on-disk manifest, and merge logic for
//! `GET /api/assistants` across builtin + user + extension sources.
pub mod builtin;
pub mod routes;
pub mod service;
pub mod state;
pub use builtin::{BuiltinAssistant, BuiltinAssistantRegistry};
pub use routes::{assistant_routes, AssistantRouterState};
pub use service::AssistantService;
Create empty module files builtin.rs, routes.rs, service.rs,
state.rs (each with a one-line doc comment).
Edit /Users/zhoukai/Documents/github/aionui-backend/Cargo.toml:
# Add to [workspace.members]:
"crates/aionui-assistant",
# Add to [workspace.dependencies]:
aionui-assistant = { path = "crates/aionui-assistant" }
Run:
cd /Users/zhoukai/Documents/github/aionui-backend
cargo build --workspace
Expected: compiles cleanly.
Determine next migration number: ls crates/aionui-db/migrations/ | sort | tail -1
→ use N+1 (example: 003_assistants.sql).
Create crates/aionui-db/migrations/NNN_assistants.sql:
CREATE TABLE assistants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
avatar TEXT,
preset_agent_type TEXT NOT NULL DEFAULT 'gemini',
enabled_skills TEXT,
custom_skill_names TEXT,
disabled_builtin_skills TEXT,
prompts TEXT,
models TEXT,
name_i18n TEXT,
description_i18n TEXT,
prompts_i18n TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_assistants_updated_at ON assistants (updated_at DESC);
CREATE TABLE assistant_overrides (
assistant_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
last_used_at INTEGER,
updated_at INTEGER NOT NULL
);
Run:
cargo test --package aionui-db
Expected: existing tests all pass (migration applies cleanly to in-memory DB).
Create crates/aionui-db/src/models/assistant.rs per backend spec §3.3
(AssistantRow, AssistantOverrideRow, CreateAssistantParams,
UpdateAssistantParams, UpsertOverrideParams — copy from spec verbatim).
Add to crates/aionui-db/src/models/mod.rs:
pub mod assistant;
pub use assistant::*;
Create crates/aionui-db/src/repository/assistant.rs with
IAssistantRepository and IAssistantOverrideRepository traits per spec
§3.4. Include async_trait.
Create crates/aionui-db/src/repository/sqlite_assistant.rs with
skeleton SqliteAssistantRepository and SqliteAssistantOverrideRepository.
For this task, return unimplemented!() in each method body — T1b
fills in actual SQL.
Wire into crates/aionui-db/src/repository/mod.rs.
Run:
cargo build --workspace
Expected: compiles (unimplemented bodies don't break build).
aionui-api-types Create crates/aionui-api-types/src/assistant.rs with exactly the types
from backend spec §3.1 and §6.2:
AssistantResponse + AssistantSourceCreateAssistantRequestUpdateAssistantRequestSetAssistantStateRequestImportAssistantsRequestImportAssistantsResultImportErrorAll #[serde(rename_all = "camelCase")].
Add to crates/aionui-api-types/src/lib.rs:
pub mod assistant;
pub use assistant::*;
Run:
cargo build --package aionui-api-types
In crates/aionui-assistant/src/state.rs, define:
use std::sync::Arc;
use aionui_db::{IAssistantRepository, IAssistantOverrideRepository};
use aionui_extension::ExtensionRegistry;
use crate::{AssistantService, BuiltinAssistantRegistry};
#[derive(Clone)]
pub struct AssistantRouterState {
pub service: Arc<AssistantService>,
}
In crates/aionui-assistant/src/routes.rs, scaffold the full route table
per backend spec §6.1, with each handler returning
Err(AppError::Internal("not implemented".into())).
Wire into aionui-app just enough to compile: add
aionui-assistant = { workspace = true } to crates/aionui-app/Cargo.toml,
and merge assistant_routes(...) in create_router guarded behind a
compile-time if true block (no auth yet — T1b completes wiring).
Run:
cargo build --workspace
cargo clippy --workspace -- -D warnings
Run:
cd /Users/zhoukai/Documents/github/aionui-backend
git add crates/aionui-assistant crates/aionui-db crates/aionui-api-types crates/aionui-app Cargo.toml Cargo.lock
git commit -m "feat(assistant): scaffold aionui-assistant crate + HTTP contract types + migration
Adds:
- crates/aionui-assistant shell with builtin/routes/service/state modules
- SQLite migration NNN_assistants.sql (assistants + assistant_overrides)
- Repository traits (IAssistantRepository, IAssistantOverrideRepository)
with unimplemented SqliteAssistant* stubs
- aionui-api-types::assistant request/response types
- Route skeleton returning 501 (implementation lands in T1b)
Ref: docs/backend-migration/specs/2026-04-23-assistant-user-data-migration-design.md"
git push
Record exact SHA: git rev-parse HEAD → store for hand-off.
SendMessage to coordinator:
"T1a complete at SHA <hex>. Frontend-dev unblocked. Starting T1b."
TaskUpdate T1a status=completed, T1b status=in_progress.
Owner: backend-dev. Depends on: T1a.
Goal: Implement AssistantService::list/create/update/delete/set_state/import
BuiltinAssistantRegistry loader + repository SQL +
in-crate unit tests. Leave HTTP E2E testing to T2.Files:
crates/aionui-db/src/repository/sqlite_assistant.rs (fill in SQL)crates/aionui-assistant/src/builtin.rscrates/aionui-assistant/src/service.rscrates/aionui-assistant/src/routes.rs (replace 501 stubs)crates/aionui-extension/src/skill_routes.rs (rule-md + skill-md dispatch)crates/aionui-app/src/lib.rs (wire real state + auth middleware)crates/aionui-app/assets/builtin-assistants/assistants.json + rule mdscrates/aionui-app/build.rs (asset placement) In sqlite_assistant.rs, implement all methods on both traits using
the same sqlx patterns as sqlite_settings.rs. Key queries:
// IAssistantRepository::list
sqlx::query_as::<_, AssistantRow>(
"SELECT * FROM assistants ORDER BY updated_at DESC"
).fetch_all(&self.pool).await
// IAssistantRepository::create — returns inserted row
// IAssistantRepository::update — partial update with COALESCE; returns Some if row existed
// IAssistantRepository::delete — returns bool (did any row get deleted)
// IAssistantRepository::upsert — still expose for other callers; NOT used by import
Inline test each public method in #[cfg(test)] with
init_database_memory() fixture. Aim ≥ 2 cases per method (happy + at
least one edge).
BuiltinAssistantRegistry In crates/aionui-assistant/src/builtin.rs:
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::Deserialize;
use tracing::{warn, error};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuiltinAssistant {
pub id: String,
pub name: String,
#[serde(default)]
pub name_i18n: HashMap<String, String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub description_i18n: HashMap<String, String>,
#[serde(default)]
pub avatar: Option<String>,
pub preset_agent_type: String,
#[serde(default)]
pub enabled_skills: Vec<String>,
#[serde(default)]
pub custom_skill_names: Vec<String>,
#[serde(default)]
pub disabled_builtin_skills: Vec<String>,
#[serde(default)]
pub rule_file: Option<String>, // relative to assets_dir, may contain "{locale}"
#[serde(default)]
pub skill_file: Option<String>, // parallel to rule_file, for /api/skills/assistant-skill/*
#[serde(default)]
pub prompts: Vec<String>,
#[serde(default)]
pub prompts_i18n: HashMap<String, Vec<String>>,
#[serde(default)]
pub models: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct BuiltinManifest {
#[serde(default)]
version: String,
#[serde(default)]
assistants: Vec<BuiltinAssistant>,
}
pub struct BuiltinAssistantRegistry {
assistants: HashMap<String, BuiltinAssistant>,
assets_dir: PathBuf,
}
impl BuiltinAssistantRegistry {
pub fn load() -> Self {
let assets_dir = match resolve_builtin_assets_dir() {
Some(p) => p,
None => {
warn!("Built-in assistants directory not resolvable; using empty registry");
return Self::empty();
}
};
let manifest_path = assets_dir.join("assistants.json");
let content = match std::fs::read_to_string(&manifest_path) {
Ok(c) => c,
Err(e) => {
warn!("Built-in manifest missing at {}: {}", manifest_path.display(), e);
return Self { assistants: HashMap::new(), assets_dir };
}
};
let manifest: BuiltinManifest = match serde_json::from_str(&content) {
Ok(m) => m,
Err(e) => {
error!("Built-in manifest parse failed: {}", e);
return Self { assistants: HashMap::new(), assets_dir };
}
};
let assistants = manifest
.assistants
.into_iter()
.map(|a| (a.id.clone(), a))
.collect();
Self { assistants, assets_dir }
}
pub fn empty() -> Self {
Self { assistants: HashMap::new(), assets_dir: PathBuf::new() }
}
pub fn all(&self) -> impl Iterator<Item = &BuiltinAssistant> {
self.assistants.values()
}
pub fn get(&self, id: &str) -> Option<&BuiltinAssistant> {
self.assistants.get(id)
}
pub fn has(&self, id: &str) -> bool {
self.assistants.contains_key(id)
}
pub fn rule_path(&self, id: &str, locale: &str) -> Option<PathBuf> {
let a = self.assistants.get(id)?;
let rel = a.rule_file.as_ref()?;
let resolved = rel.replace("{locale}", locale);
Some(self.assets_dir.join(resolved))
}
pub fn skill_path(&self, id: &str, locale: &str) -> Option<PathBuf> {
let a = self.assistants.get(id)?;
let rel = a.skill_file.as_ref()?;
let resolved = rel.replace("{locale}", locale);
Some(self.assets_dir.join(resolved))
}
pub fn avatar_path(&self, id: &str) -> Option<PathBuf> {
let a = self.assistants.get(id)?;
let rel = a.avatar.as_ref()?;
Some(self.assets_dir.join(rel))
}
}
fn resolve_builtin_assets_dir() -> Option<PathBuf> {
if let Ok(env) = std::env::var("AIONUI_BUILTIN_ASSISTANTS_PATH") {
return Some(PathBuf::from(env));
}
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?.join("assets").join("builtin-assistants");
if dir.exists() { return Some(dir); }
// Dev fallback: cargo run from workspace root
let cargo_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?;
let dev = PathBuf::from(cargo_dir)
.parent()?
.join("aionui-app")
.join("assets")
.join("builtin-assistants");
if dev.exists() { return Some(dev); }
None
}
Inline tests in #[cfg(test)]: happy load / missing dir / malformed
JSON / empty list / path resolution with {locale}.
AssistantService Implement per backend spec §5 — list/get/create/update/delete/set_state/import
and rule/skill dispatch helpers (read_rule, write_rule, delete_rule,
same for _skill, classify).
Implement AssistantSource classification using:
BuiltinAssistantRegistry::hasExtensionRegistry::has_assistant (add this helper if missing — see
§1b.3a below)AssistantSource::User Critical import implementation — insert-only per backend spec §6.3:
pub async fn import(&self, req: ImportAssistantsRequest) -> Result<ImportAssistantsResult, AppError> {
let mut result = ImportAssistantsResult::default();
for req in req.assistants {
let id = req.id.clone().unwrap_or_else(|| generate_user_id());
// Skip: built-in id conflict
if self.builtin.has(&id) { result.skipped += 1; continue; }
// Skip: extension id conflict
if self.extension_registry.has_assistant(&id).await {
result.skipped += 1; continue;
}
// Skip: already-imported user row
if self.repo.get(&id).await?.is_some() {
result.skipped += 1; continue;
}
// Insert
let params = CreateAssistantParams::from_request(&id, &req)?;
match self.repo.create(¶ms).await {
Ok(_) => result.imported += 1,
Err(e) => {
result.failed += 1;
result.errors.push(ImportError { id, error: e.to_string() });
}
}
}
Ok(result)
}
Merge logic in list() per spec §5.1 — preserve sort order correctly
(sort_order asc, last_used_at desc fallback).
Inline tests per backend spec §9.1 — every behavior row mapped to a named test.
ExtensionRegistry If ExtensionRegistry::has_assistant(id) and
get_assistant_by_id(id) don't exist, add them to
crates/aionui-extension/src/registry.rs:
pub async fn has_assistant(&self, id: &str) -> bool {
self.get_assistants().await.iter().any(|a| a.id == id)
}
pub async fn get_assistant_by_id(&self, id: &str) -> Option<ResolvedAssistant> {
self.get_assistants().await.into_iter().find(|a| a.id == id)
}
No new test file needed — existing get_assistants tests cover the
lookup primitive.
aionui-extension Extend crates/aionui-extension/src/skill_routes.rs handlers for:
POST /api/skills/assistant-rule/readPOST /api/skills/assistant-rule/writeDELETE /api/skills/assistant-rule/{assistantId}POST /api/skills/assistant-skill/readPOST /api/skills/assistant-skill/writeDELETE /api/skills/assistant-skill/{assistantId} Each handler calls into AssistantClassifier::classify — define this
trait in aionui-common::traits (or aionui-extension's own module; pick
wherever keeps dep graph cleanest):
#[async_trait::async_trait]
pub trait AssistantClassifier: Send + Sync {
async fn classify(&self, id: &str) -> AssistantSource;
}
Wire AssistantService to implement this trait so
crates/aionui-extension/src/skill_routes.rs can depend on the trait
without pulling in aionui-assistant directly.
In each handler:
read → dispatch to builtin rule_path / extension resolved_rule_content /
user file under ~/.aionui/assistant-rules/ (or -skills/). Missing →
empty string.write → 400 for builtin/extension; user writes go through.delete → same 400 rule. Preserve existing 7 endpoints' response shapes (regression must stay
green — see modules/assistant.md).
Add integration tests to crates/aionui-extension/tests/ covering
the three dispatch paths for both rule and skill.
Create crates/aionui-app/assets/builtin-assistants/assistants.json.
Content source: take the existing frontend
src/common/config/presets/assistantPresets.ts array (from the AionUi
repo), translate each entry to the manifest schema (§4.2 of backend spec).
Keep the full PRESET_ID_WHITELIST in sync — both the backend manifest
and the T3b whitelist must list the same ids.
For each entry with a rule file, copy the existing md files under
~/Library/Application Support/AionUi-Dev/config/assistants/*.md (or
equivalent in the repo at frontend src/... if committed there) into
crates/aionui-app/assets/builtin-assistants/rules/{id}.{locale}.md.
Commit the PRESET_ID_WHITELIST list separately as a JSON fixture at
crates/aionui-app/assets/builtin-assistants/preset-id-whitelist.json for
the frontend migration hook to read (or mirror in frontend code — T3b
decides).
Create crates/aionui-app/build.rs:
use std::fs;
use std::path::{Path, PathBuf};
fn main() {
println!("cargo:rerun-if-changed=assets/builtin-assistants");
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/builtin-assistants");
if !src.exists() {
println!("cargo:warning=assets/builtin-assistants missing; skipping copy");
return;
}
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
// OUT_DIR is e.g. target/<profile>/build/<pkg>-<hash>/out
// Walk up to target/<profile>/
let target_dir = out_dir
.ancestors()
.nth(3)
.expect("could not locate target/<profile>");
let dst = target_dir.join("assets/builtin-assistants");
copy_dir_recursive(&src, &dst).expect("failed to copy built-in assets");
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
Run:
cargo build --workspace
ls target/debug/assets/builtin-assistants/
Expected: assistants.json + rules/ present.
In crates/aionui-app/src/lib.rs AppServices::from_database_with_data_dir:
let builtin_registry = Arc::new(BuiltinAssistantRegistry::load());
let assistant_repo: Arc<dyn IAssistantRepository> =
Arc::new(SqliteAssistantRepository::new(pool.clone()));
let assistant_override_repo: Arc<dyn IAssistantOverrideRepository> =
Arc::new(SqliteAssistantOverrideRepository::new(pool.clone()));
let assistant_service = Arc::new(AssistantService::new(
assistant_repo,
assistant_override_repo,
builtin_registry,
extension_registry.clone(),
));
// Provide classifier for skill-md dispatch
let assistant_classifier: Arc<dyn AssistantClassifier> = assistant_service.clone();
In create_router, merge assistant_routes(...) behind auth middleware
(follow the system_authenticated pattern):
let assistant_authenticated =
assistant_routes(states.assistant.clone())
.route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware));
// ...
.merge(assistant_authenticated)
Wire the classifier into existing skill_routes(...) call site.
Run:
cargo fmt --all
cargo clippy --workspace -- -D warnings
cargo test --workspace
All must pass.
Run:
git add -A
git commit -m "feat(assistant): implement AssistantService, builtin loader, rule/skill dispatch
- SqliteAssistantRepository + SqliteAssistantOverrideRepository
- BuiltinAssistantRegistry with locale-resolved rule/skill/avatar paths
- AssistantService: list merge (builtin + user + extension), CRUD,
set_state, import (insert-only), classify
- AssistantClassifier trait used by aionui-extension skill_routes
- /api/skills/assistant-rule/* and /api/skills/assistant-skill/* now
dispatch per source
- Built-in assets shipped to target/<profile>/assets/builtin-assistants
via aionui-app/build.rs
- Inline unit tests across service, builtin, repository
Ref: docs/backend-migration/specs/2026-04-23-assistant-user-data-migration-design.md §5, §6.4, §6.4a"
git push
SendMessage to coordinator + backend-tester:
"T1b complete at SHA <hex>. backend-tester unblocked."
Install binary for E2E reuse:
cargo install --path crates/aionui-app
ls -la ~/.cargo/bin/aionui-backend # verify fresh timestamp
TaskUpdate T1b status=completed.
Owner: backend-tester. Depends on: T1b.
Goal: Probe every endpoint via tower::ServiceExt::oneshot. Produce
crates/aionui-app/tests/assistants_e2e.rs + smoke-probe transcript.
Branch: feat/assistant-user-data.
"backend-tester alive on T2"cd /Users/zhoukai/Documents/github/aionui-backend
git pull --ff-only
git rev-parse HEAD # matches T1b SHA
cargo test --workspace --no-run
Create crates/aionui-app/tests/assistants_e2e.rs with fixture helpers
mirroring crates/aionui-app/tests/system_version_e2e.rs:
AIONUI_BUILTIN_ASSISTANTS_PATH to a temp dir seeded with a
minimal assistants.json (2 built-ins)create_routerget_with_token, post_with_token, put_with_token, patch_with_token, delete_with_tokenWrite one happy + one error test per endpoint from backend spec §6:
GET /api/assistants (empty + populated)POST /api/assistants (happy / name empty / builtin conflict / ext conflict / user conflict)PUT /api/assistants/{id} (happy / 404 / builtin reject / ext reject)DELETE /api/assistants/{id} (happy + fs cleanup / builtin reject / ext reject)PATCH /api/assistants/{id}/state (insert / update / ext reject / 404)POST /api/assistants/import (happy / builtin collision skip / ext collision skip / existing user skip / retry idempotency)GET /api/assistants/{id}/avatar (builtin / user / 404)POST /api/skills/assistant-rule/read (all three dispatch paths)POST /api/skills/assistant-rule/write (user happy / builtin 400 / ext 400)/api/skills/assistant-skill/*Run:
cargo test --test assistants_e2e --nocapture
All green.
On this dev machine (macOS) run cargo build --release + start
~/.cargo/bin/aionui-backend --local --port 25900, then:
curl -s http://127.0.0.1:25900/api/assistants | jq '.data | length'
Expected: >= 2 (built-ins loaded).
If access to Linux/Windows CI runners: run same probe there. If no
access, SendMessage coordinator: "Cross-platform validation for L/W pending CI runner access; spec §12 DoD gate" — coordinator decides
whether to block or scope as follow-up.
Create docs/backend-migration/handoffs/backend-tester-assistant-user-data-2026-04-23.md
with: test file path, all probe commands + outputs, per-endpoint
pass/fail summary, cross-platform status, open gaps.
Commit + push:
git add crates/aionui-app/tests/assistants_e2e.rs docs/backend-migration/handoffs/backend-tester-assistant-user-data-2026-04-23.md
git commit -m "test(assistant): HTTP integration suite for /api/assistants/* and rule/skill dispatch"
git push
SendMessage coordinator: "T2 complete. Probe transcript in handoff. <N>/<N> tests green."
TaskUpdate T2 status=completed.
Owner: frontend-dev. Depends on: T1a (for HTTP contract types).
Goal: All 14 production files rewritten off ConfigStorage.*assistants
onto ipcBridge.assistants.*. No main-process migration yet (that's 3b).
Branch: feat/backend-migration-assistant-user-data (AionUi).
"frontend-dev alive on T3a"cd /Users/zhoukai/Documents/github/AionUi
git pull --ff-only
git rev-parse --abbrev-ref HEAD # assistants branch
Create src/common/types/assistantTypes.ts:
// Mirror of aionui-api-types/src/assistant.rs.
// Any shape change on either side requires same-PR update on the other.
export type AssistantSource = 'builtin' | 'user' | 'extension';
export interface Assistant {
id: string;
source: AssistantSource;
name: string;
nameI18n: Record<string, string>;
description?: string;
descriptionI18n: Record<string, string>;
avatar?: string;
enabled: boolean;
sortOrder: number;
presetAgentType: string;
enabledSkills: string[];
customSkillNames: string[];
disabledBuiltinSkills: string[];
context?: string;
contextI18n: Record<string, string>;
prompts: string[];
promptsI18n: Record<string, string[]>;
models: string[];
lastUsedAt?: number;
}
export interface CreateAssistantRequest {
id?: string;
name: string;
description?: string;
avatar?: string;
presetAgentType?: string;
enabledSkills?: string[];
customSkillNames?: string[];
disabledBuiltinSkills?: string[];
prompts?: string[];
models?: string[];
nameI18n?: Record<string, string>;
descriptionI18n?: Record<string, string>;
promptsI18n?: Record<string, string[]>;
}
export interface UpdateAssistantRequest extends Partial<Omit<CreateAssistantRequest, 'id'>> {
id: string;
}
export interface SetAssistantStateRequest {
id: string;
enabled?: boolean;
sortOrder?: number;
lastUsedAt?: number;
}
export interface ImportAssistantsRequest {
assistants: CreateAssistantRequest[];
}
export interface ImportAssistantsResult {
imported: number;
skipped: number;
failed: number;
errors: Array<{ id: string; error: string }>;
}
Update src/common/config/storage.ts:
assistants?: AcpBackendConfig[] from IConfigStorageRefermigration.electronConfigImported?: boolean Update src/renderer/pages/settings/AssistantSettings/types.ts:
import type { Assistant } from '@/common/types/assistantTypes';
export type AssistantListItem = Assistant;
Add to src/common/adapter/ipcBridge.ts:
// ---------------------------------------------------------------------------
// Assistant — routed to /api/assistants/*
// ---------------------------------------------------------------------------
export const assistants = {
list: httpGet<Assistant[], void>('/api/assistants'),
create: httpPost<Assistant, CreateAssistantRequest>('/api/assistants'),
update: httpPut<Assistant, UpdateAssistantRequest>((p) => `/api/assistants/${p.id}`),
delete: httpDelete<void, { id: string }>((p) => `/api/assistants/${p.id}`),
setState: httpPatch<Assistant, SetAssistantStateRequest>((p) => `/api/assistants/${p.id}/state`),
import: httpPost<ImportAssistantsResult, ImportAssistantsRequest>('/api/assistants/import'),
};
Import Assistant, request types from @/common/types/assistantTypes.
useAssistantList Edit src/renderer/hooks/assistant/useAssistantList.ts:
import { ipcBridge } from '@/common';
import { resolveLocaleKey } from '@/common/utils';
import type { Assistant } from '@/common/types/assistantTypes';
import { sortAssistants as sortAssistantsUtil } from '@/renderer/pages/settings/AssistantSettings/assistantUtils';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const useAssistantList = () => {
const { i18n } = useTranslation();
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [activeAssistantId, setActiveAssistantId] = useState<string | null>(null);
const localeKey = resolveLocaleKey(i18n.language);
const loadAssistants = useCallback(async () => {
try {
const list = await ipcBridge.assistants.list.invoke();
const sorted = sortAssistantsUtil(list);
setAssistants(sorted);
setActiveAssistantId((prev) => {
if (prev && sorted.some((a) => a.id === prev)) return prev;
return sorted[0]?.id ?? null;
});
} catch (error) {
console.error('Failed to load assistants:', error);
}
}, []);
useEffect(() => {
void loadAssistants();
}, [loadAssistants]);
const activeAssistant = assistants.find((a) => a.id === activeAssistantId) ?? null;
return {
assistants,
setAssistants,
activeAssistantId,
setActiveAssistantId,
activeAssistant,
loadAssistants,
localeKey,
};
};
assistantUtils.tssrc/renderer/pages/settings/AssistantSettings/assistantUtils.ts:
normalizeExtensionAssistantsisExtensionAssistantgetAssistantSourcesortAssistants: keep the sort but use sortOrder as the
primary key (backend already sorts, so this is a safe fallback):
export const sortAssistants = (list: Assistant[]): Assistant[] =>
[...list].sort((a, b) => a.sortOrder - b.sortOrder);
isEmoji, resolveAvatarImageSrcuseAssistantEditor Edit src/renderer/hooks/assistant/useAssistantEditor.ts — replace
all 4 ConfigStorage.get/set('assistants') sites:
ipcBridge.assistants.create.invoke({ ... })ipcBridge.assistants.update.invoke({ id, ...changes })ipcBridge.assistants.delete.invoke({ id })ipcBridge.assistants.setState.invoke({ id, enabled }) Replace activeAssistant?.isBuiltin checks with
activeAssistant?.source === 'builtin'.
Replace isExtensionAssistant(activeAssistant) with
activeAssistant?.source === 'extension'.
Rule-md read/write calls stay unchanged (existing
ipcBridge.fs.readAssistantRule / writeAssistantRule — their dispatch
change is transparent to the frontend).
Edit each to swap ConfigStorage.get('assistants') for
ipcBridge.assistants.list.invoke(). Keep acp.customAgents unchanged.
src/renderer/hooks/agent/usePresetAssistantInfo.tssrc/renderer/pages/conversation/hooks/useConversationAgents.tssrc/renderer/pages/guid/hooks/useCustomAgentsLoader.tssrc/renderer/pages/guid/hooks/usePresetAssistantResolver.tssrc/renderer/pages/settings/AgentSettings/PresetManagement.tsx (3 sites)src/renderer/pages/conversation/components/SkillRuleGenerator.tsx (2 sites)ASSISTANT_PRESETS consumers (init-order compliance)Run:
grep -rn "ASSISTANT_PRESETS\|assistantPresets" src/ | grep -v __tests__
For each site, confirm the consumer already runs inside useEffect or
an async function (renderer) OR restructure the init order (main process)
to await ipcBridge.assistants.list before use. Target files per spec §7.6:
src/process/team/mcp/team/TeamMcpServer.tssrc/process/team/prompts/teamGuideAssistant.tssrc/common/utils/presetAssistantResources.ts Delete src/common/config/presets/assistantPresets.ts.
Delete src/common/utils/presetAssistantResources.ts (or reduce to a
thin pass-through if any non-assistant code still imports it — check with
grep first).
Run:
grep -rn "ConfigStorage.*'assistants'" src/ | grep -v __tests__ | grep -v '\.test\.'
Expected: zero matches.
Run:
bunx tsc --noEmit
bun run lint --quiet
Both must pass with no new errors or warnings.
Run:
git add -A
git commit -m "refactor(assistant): swap ConfigStorage reads/writes for ipcBridge.assistants.*
- Introduce src/common/types/assistantTypes.ts (mirror of Rust types)
- Add ipcBridge.assistants.{list,create,update,delete,setState,import}
- Rewrite useAssistantList, useAssistantEditor, 6 consumer hooks/pages
- Prune assistantUtils: drop normalizeExtensionAssistants,
isExtensionAssistant, getAssistantSource
- Delete assistantPresets.ts + presetAssistantResources.ts
- ConfigStorage.*'assistants' production grep now zero
Single-source-of-truth invariant: no frontend code writes the legacy
'assistants' key after this commit. Main-process migration hook lands
in T3b.
Ref: docs/backend-migration/specs/2026-04-23-assistant-user-data-migration-design.md §7"
git push
SendMessage coordinator + frontend-tester + e2e-tester:
"T3a complete. SHA <hex>. frontend-tester unblocked."
TaskUpdate T3a status=completed, T3b status=in_progress.
Owner: frontend-dev. Depends on: T3a.
Goal: One-shot import of legacy ConfigStorage.get('assistants') into
backend after backend is healthy.
Create src/process/utils/migrateAssistants.ts:
import { ipcBridge } from '@/common';
import type { CreateAssistantRequest } from '@/common/types/assistantTypes';
import type { ProcessConfig } from './initStorage';
const BUILTIN_ID_PREFIX = 'builtin-';
// Frozen snapshot — must match crates/aionui-app/assets/builtin-assistants/
// preset-id-whitelist.json (or the assistants.json manifest). Refresh when
// the backend manifest changes.
const PRESET_ID_WHITELIST = new Set<string>([
// TODO_FIXED_AT_T1B_6: populate from the whitelist file shipped with
// backend. Replaced by frontend-dev in this step with the actual ids.
]);
function isLegacyBuiltin(a: Record<string, unknown>): boolean {
const id = typeof a.id === 'string' ? a.id : '';
return id.startsWith(BUILTIN_ID_PREFIX) || PRESET_ID_WHITELIST.has(id);
}
function generateCollisionId(): string {
const ms = Date.now();
const hex = Math.floor(Math.random() * 0xffff)
.toString(16)
.padStart(4, '0');
return `custom-migrated-${ms}-${hex}`;
}
function toBackendShape(legacy: Record<string, unknown>): CreateAssistantRequest {
const legacyId = typeof legacy.id === 'string' ? legacy.id : '';
// Rename colliding user-authored ids to preserve data (spec §8.1)
const id = PRESET_ID_WHITELIST.has(legacyId) ? generateCollisionId() : legacyId;
return {
id,
name: (legacy.name as string) ?? 'Untitled',
description: legacy.description as string | undefined,
avatar: legacy.avatar as string | undefined,
presetAgentType: typeof legacy.presetAgentType === 'string' ? (legacy.presetAgentType as string) : 'gemini',
enabledSkills: (legacy.enabledSkills as string[]) ?? [],
customSkillNames: (legacy.customSkillNames as string[]) ?? [],
disabledBuiltinSkills: (legacy.disabledBuiltinSkills as string[]) ?? [],
prompts: (legacy.prompts as string[]) ?? [],
models: (legacy.models as string[]) ?? [],
nameI18n: (legacy.nameI18n as Record<string, string>) ?? {},
descriptionI18n: (legacy.descriptionI18n as Record<string, string>) ?? {},
promptsI18n: (legacy.promptsI18n as Record<string, string[]>) ?? {},
};
}
export async function migrateAssistantsToBackend(configFile: ProcessConfig): Promise<void> {
if (process.env.AIONUI_SKIP_ELECTRON_MIGRATION === '1') {
console.log('[AionUi] Assistant migration skipped (env flag set)');
return;
}
const imported = await configFile.get('migration.electronConfigImported').catch(() => false);
if (imported) return;
const legacy = ((await configFile.get('assistants').catch(() => [])) as Record<string, unknown>[]) ?? [];
const userAssistants = legacy.filter((a) => !isLegacyBuiltin(a));
if (userAssistants.length === 0) {
await configFile.set('migration.electronConfigImported', true);
return;
}
try {
const result = await ipcBridge.assistants.import.invoke({
assistants: userAssistants.map(toBackendShape),
});
if (result.failed === 0) {
await configFile.set('migration.electronConfigImported', true);
console.log(`[AionUi] Migrated ${result.imported} assistants (skipped ${result.skipped})`);
} else {
console.error(`[AionUi] Assistant migration partial: ${result.failed} failed`, result.errors);
}
} catch (error) {
console.error('[AionUi] Assistant migration failed:', error);
}
}
initStorage.ts Edit src/process/utils/initStorage.ts to call
migrateAssistantsToBackend(configFile) after ConfigStorage.interceptor
setup AND after backend readiness is confirmed by the caller.
The backend-ready gate lives in src/index.ts (main process startup).
Add after backendManager.start() resolves:
// src/index.ts (in whichever function owns backend bootstrap)
await backendManager.start(dbPath);
mark(`backendManager.start (port=${backendPort})`);
// New: one-shot assistant migration. Must run after backend is healthy.
await migrateAssistantsToBackend(ProcessConfig);
mark('migrateAssistantsToBackend');
Import ProcessConfig from ./process/utils/initStorage (it's already
exported).
Read crates/aionui-app/assets/builtin-assistants/assistants.json
(committed by T1b.5). Extract all id values.
Paste them into PRESET_ID_WHITELIST in migrateAssistants.ts.
Document in a code comment that this list must stay in sync:
// Kept in sync with assistants.json manifest in aionui-backend repo.
// If the backend adds/removes a built-in id, update this list in the
// same PR. A drift means built-in user-edits silently migrate into the
// user table (data loss on next backend upgrade).
Create tests/unit/migrateAssistants.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { migrateAssistantsToBackend } from '@/process/utils/migrateAssistants';
// Mock ipcBridge
vi.mock('@/common', () => ({
ipcBridge: {
assistants: {
import: { invoke: vi.fn() },
},
},
}));
function makeConfigFile(initial: Record<string, unknown>) {
const store = new Map(Object.entries(initial));
return {
get: vi.fn(async (k: string) => store.get(k)),
set: vi.fn(async (k: string, v: unknown) => {
store.set(k, v);
}),
};
}
describe('migrateAssistantsToBackend', () => {
beforeEach(() => vi.clearAllMocks());
it('skips when migration already complete', async () => {
const cf = makeConfigFile({ 'migration.electronConfigImported': true });
await migrateAssistantsToBackend(cf as any);
expect(cf.set).not.toHaveBeenCalled();
});
it('filters builtin-prefixed rows', async () => {
const cf = makeConfigFile({
'migration.electronConfigImported': false,
assistants: [
{ id: 'builtin-office', name: 'Office' },
{ id: 'custom-123', name: 'Mine' },
],
});
const { ipcBridge } = await import('@/common');
(ipcBridge.assistants.import.invoke as any).mockResolvedValue({
imported: 1,
skipped: 0,
failed: 0,
errors: [],
});
await migrateAssistantsToBackend(cf as any);
expect(ipcBridge.assistants.import.invoke).toHaveBeenCalledWith({
assistants: expect.arrayContaining([expect.objectContaining({ id: 'custom-123' })]),
});
expect(ipcBridge.assistants.import.invoke).toHaveBeenCalledWith({
assistants: expect.not.arrayContaining([expect.objectContaining({ id: 'builtin-office' })]),
});
});
it('does not set flag on partial failure', async () => {
const cf = makeConfigFile({
'migration.electronConfigImported': false,
assistants: [{ id: 'a', name: 'A' }],
});
const { ipcBridge } = await import('@/common');
(ipcBridge.assistants.import.invoke as any).mockResolvedValue({
imported: 0,
skipped: 0,
failed: 1,
errors: [{ id: 'a', error: '...' }],
});
await migrateAssistantsToBackend(cf as any);
expect(cf.set).not.toHaveBeenCalledWith('migration.electronConfigImported', true);
});
it('sets flag when nothing to migrate', async () => {
const cf = makeConfigFile({
'migration.electronConfigImported': false,
assistants: [{ id: 'builtin-office', name: 'Office' }], // all filtered
});
await migrateAssistantsToBackend(cf as any);
expect(cf.set).toHaveBeenCalledWith('migration.electronConfigImported', true);
});
it('respects AIONUI_SKIP_ELECTRON_MIGRATION=1', async () => {
process.env.AIONUI_SKIP_ELECTRON_MIGRATION = '1';
const cf = makeConfigFile({
'migration.electronConfigImported': false,
assistants: [{ id: 'custom-1', name: 'X' }],
});
await migrateAssistantsToBackend(cf as any);
expect(cf.set).not.toHaveBeenCalled();
delete process.env.AIONUI_SKIP_ELECTRON_MIGRATION;
});
it('renames colliding whitelist ids to custom-migrated-*', async () => {
// Add a known id to whitelist via module mutation OR
// craft a test that uses an actual whitelist entry. For now, rely
// on the prefix path which is more deterministic; this case is
// covered by the manifest-integration test in T5.
});
});
Run:
bun run test --run tests/unit/migrateAssistants.test.ts
All green.
Run:
git add -A
git commit -m "feat(assistant): main-process one-shot migration from ConfigStorage to backend
- src/process/utils/migrateAssistants.ts with id prefix + whitelist
classification and collision rename (custom-migrated-<ms>-<hex>)
- Invoked from src/index.ts after backendManager.start()
- Honors AIONUI_SKIP_ELECTRON_MIGRATION=1 for E2E tests
- migration.electronConfigImported flag prevents re-run
- Unit tests cover: already-done skip / filter / partial failure /
empty-after-filter / env skip
Ref: docs/backend-migration/specs/2026-04-23-assistant-user-data-migration-design.md §8"
git push
SendMessage coordinator + e2e-tester:
"T3b complete. SHA <hex>. e2e-tester still blocked on T2 + T4."
TaskUpdate T3b status=completed.
Owner: frontend-tester. Depends on: T3a (NOT T3b — hooks are independently testable).
Branch: same as T3a. After T3b lands, pull + rerun.
"frontend-tester alive on T4"cd /Users/zhoukai/Documents/github/AionUi
git pull --ff-only
New: tests/unit/assistantsBridge.test.ts — mock fetch, exercise
all 6 bridge methods (list/create/update/delete/setState/import). Verify:
{success,data} envelope) Update: tests/unit/assistantHooks.dom.test.ts — swap the old
ConfigStorage mocks for ipcBridge.assistants.* mocks. Cover:
useAssistantList loads from ipcBridge.assistants.listuseAssistantEditor create/update/delete/toggle call correct bridgesource === 'user' gates edit/delete buttonssource === 'builtin' / 'extension' disables edit UI Prune: tests/unit/assistantUtils.test.ts — remove
isExtensionAssistant / getAssistantSource tests; keep isEmoji /
resolveAvatarImageSrc / simplified sortAssistants.
Run:
bun run test --run tests/unit/assistants*.test.ts tests/unit/assistantHooks.dom.test.ts tests/unit/migrateAssistants.test.ts
All green.
Run:
bunx tsc --noEmit
bun run lint --quiet
bun run test --run
All pass with no new warnings. If lint warnings introduced, fix atomically
with commit test(assistant): align lint/tsc with refactored hooks.
Create
docs/backend-migration/handoffs/frontend-tester-assistant-user-data-2026-04-23.md
with: per-suite pass/fail counts, new test file list, lint/tsc diff
(before/after), anything surfaced.
Commit + push:
git add tests/unit/*assistants* tests/unit/migrateAssistants.test.ts \
tests/unit/assistantHooks.dom.test.ts tests/unit/assistantUtils.test.ts \
docs/backend-migration/handoffs/frontend-tester-assistant-user-data-2026-04-23.md
git commit -m "test(assistant): unit coverage for bridge + hooks + migration"
git push
SendMessage coordinator + e2e-tester:
"T4 complete at SHA <hex>."
TaskUpdate T4 status=completed.
Owner: e2e-tester. Depends on: T2 and T4.
Branch: feat/backend-migration-assistant-user-data.
"e2e-tester alive on T5"cd /Users/zhoukai/Documents/github/AionUi
git pull --ff-only
which aionui-backend
# ~/.cargo/bin/aionui-backend is a symlink per workflow doc §2; -L follows it
stat -Lf "%Sm" ~/.cargo/bin/aionui-backend # must reflect fresh debug build
readlink ~/.cargo/bin/aionui-backend # must resolve to target/debug/aionui-backend
bunx electron-vite build
stat -f "%Sm" out/renderer/index.html # fresh today
Create tests/e2e/features/assistants-user-data/ with a single file
assistant-user-data.e2e.ts covering:
assistant_overrides row
inserted; restart backend → toggle persistsaionui-config.txt with 3 user
custom-migrated-* id, original
content preserved Reuse tests/e2e/helpers/ fixtures; if assistantSettings.ts helper
exists from prior assistant-verification pilot, extend it rather than
forking.
Run:
bun run test:e2e tests/e2e/features/assistants-user-data/
ETA: 30–60 min for 10 tests at ~60s each.
Use the Skill-Library pilot rubric:
Create
docs/backend-migration/e2e-reports/2026-04-23-assistant-user-data.md
with: pass/fail matrix, classifications, curl backend probes used for
Class D/F hypotheses, verdict per scenario.
Create
docs/backend-migration/handoffs/e2e-tester-assistant-user-data-2026-04-23.md
summarizing routing decisions.
Commit + push both.
All green or only Class B/C/E: SendMessage coordinator:
"T5 clean. No Class D/F." TaskUpdate completed.
Class D/F present: SendMessage coordinator with per-failure routing. Coordinator spawns an ad-hoc backend-dev or frontend-dev re-engagement for targeted fixes; T5 re-runs after fix lands.
Owner: coordinator. Depends on: T5.
cd /Users/zhoukai/Documents/github/AionUi
git checkout feat/backend-migration-coordinator
git merge origin/feat/backend-migration-assistant-user-data --no-edit
git push
Per user instruction during pilot execution: do NOT raise cross-repo PRs in T6. Branches stay pushed on both origins for the user to inspect; PR creation (if any) is a manual step the user will do later outside this pilot.
Create
docs/backend-migration/handoffs/coordinator-assistant-user-data-2026-04-23.md
with: outcomes, lessons (esp. team-mode coordination across two repos),
open follow-ups (pending follow-up specs: acp.customAgents migration,
built-in-skill migration, other ConfigStorage.* dual-write residuals).
Commit + push.
docs/backend-migration/modules/assistant.md:
SendMessage shutdown_request to all teammates who are still alive.
TaskUpdate T6 status=completed.
Final SendMessage to user with branch tips (both repos), pushed SHAs, and summary.
Mirror of §12 (backend spec) and §10.4 (frontend spec):
grep -rn "ConfigStorage.*'assistants'"
across src/ production zero.aionui-assistant crate merged; cargo test --workspace green;
cargo clippy -- -D warnings clean; cargo fmt --all -- --check clean.crates/aionui-app/tests/assistants_e2e.rs green.aionui-extension rule-md + skill-md dispatch tests green.bunx tsc --noEmit + bun run lint all clean.migration.electronConfigImported=true after launch.src/common/config/presets/assistantPresets.ts deleted.assets/builtin-assistants/ populated and shipped via
build.rs; runtime probe on macOS/Linux/Windows.