Leave Off for non-reasoning models. Higher = more reasoning, more tokens. Anthropic: "Fetch levels" autodiscovers supported values; other providers: see the docs link and type the value.
+
Heads up: "" isn't a recognized reasoning model — only set a level if you know it supports thinking, or the inference call may error.
+
+{%- endmacro %}
+
+{# ── Memory pagination ──────────────────────────────────────── #}
+{%- macro memory_pag(view, total, suffix='') %}
+{% set off = memory_offset|default(0)|int %}
+{% set lim = memory_limit|default(25)|int %}
+{% set q = memory_q|default('') %}
+{% set page_sizes = [25, 50, 100] %}
+{% set total = total|default(0)|int %}
+{% set cp = (off // lim) + 1 if total > 0 else 1 %}
+{% set tp = ((total + lim - 1) // lim) if total > 0 else 1 %}
+{% set ss = off + 1 if total > 0 else 0 %}
+{% set se = [(off + lim), total]|min if total > 0 else 0 %}
+{% set qs = ('&q=' + q|urlencode) if q else '' %}
+{% set pid = 'ps-' + view + suffix %}
+
+
+
+ {%- if total > 0 %}Showing {{ ss }}–{{ se }} of {{ total }}{% else %}No entries{% endif -%}
+
+
+
+
+
+
+
+ Page {{ cp }} / {{ tp }}
+
+
+
+
+{%- endmacro %}
+
+{# ── Memory search ──────────────────────────────────────────── #}
+{%- macro memory_search(view) %}
+{% set off = memory_offset|default(0)|int %}
+{% set lim = memory_limit|default(25)|int %}
+{% set q = memory_q|default('') %}
+
+
+
+
+{%- endmacro %}
+
+{# ── Memory category list ───────────────────────────────────── #}
+{% set memory_categories = ['preference','relationship','fact','routine','work','health','travel'] %}
diff --git a/humux/api/templates/partials/jobs.html b/humux/api/templates/partials/jobs.html
index 91505c0..052109d 100644
--- a/humux/api/templates/partials/jobs.html
+++ b/humux/api/templates/partials/jobs.html
@@ -1,357 +1,18 @@
-{# Jobs tab partial #}
-{% set job_agents = jobs | map(attribute='agent') | reject('equalto', '') | unique | sort %}
-
- {# Sub-tabs: Scheduled | Subagents — selection remembered in URL hash #}
+{# Jobs tab wrapper: Scheduled | Subagents sub-tabs.
+ Each sub-tab lazy-loads its own partial via htmx. The active sub-tab
+ is mirrored in the URL as ?jobsub= for copy-pasteable links. #}
+
-
-
-
- {# ── Scheduled jobs ───────────────────────────────────────── #}
-
-
-
- Refresh
-
- {{ jobs|length }} job{{ 's' if jobs|length != 1 else '' }}
- {% if job_agents %}
-
- {% endif %}
- {% if not agent_running %}
- agent stopped — jobs won't run
- {% endif %}
-
-
Leave Off for non-reasoning models. Higher = more reasoning, more tokens. Anthropic: “Fetch levels” autodiscovers supported values; other providers: see the docs link and type the value.
-
Heads up: “” isn't a recognized reasoning model — only set a level if you know it supports thinking, or the inference call may error.
-
-{%- endmacro %}
+{# LLM tab wrapper — Inference | Providers | History sub-tabs.
+ Holds the Alpine x-data with all LLM state. Each sub-tab is lazy-loaded
+ via htmx into #llm-sub (inside the Alpine scope so x-model etc. reach
+ the parent component). #}
+{% import 'partials/_macros.html' as m %}
- {# Sub-tabs (issue #114): split the crowded LLM settings into views.
- SysPrompt sub-tab removed (#137) — the Inspect tab shows the full
- assembled system prompt directly. #}
+ {# Sub-tabs: Inference | Providers | History #}
InferenceProvidersHistory
-
-
-
-
Active Inference Provider
-
Select the provider used for agent inference after testing its connection.
In shared/group chats with several bots and people, decide per message whether to reply at all — staying quiet for messages aimed at another bot, caught in a bot-to-bot reaction loop, or that the agent can't usefully add to. Off by default; uses a cheap/fast model for the yes/no call.
Model used to summarize older conversation turns when a session's context grows large. Enable compaction and set the trigger threshold in the History sub-tab.
When the active model can’t read images, route image attachments to this model for a text description / OCR, injected in place of the image. Off by default; engages only when the active model lacks native vision. Reuses the provider keys configured below.
-
-
-
-
- saveVision())">Save vision fallback
-
-
-
-
-
-
{# /inference #}
-
-
-
- 🔒 Provider API keys are stored in the vault on save — only a ${vault:NAME} reference is kept in config, never the raw key. Manage or reuse them from the Vaults tab.
-
-
-
Anthropic
-
Configure the Anthropic API key and test the connection.
A provider-agnostic gateway: one API key reaches models from many labs (Anthropic, OpenAI, Google, Meta, DeepSeek, …). Model ids are namespaced provider/model (e.g. anthropic/claude-sonnet-4.5) — use Fetch models for the live catalog.
-
OpenRouter can auto-route around provider outages and fall back between hosts, and publishes transparent per-token pricing per model. Docs: openrouter.ai/docs · Models & pricing: openrouter.ai/models · Routing & fallbacks: provider routing.
In shared/group chats with several bots and people, decide per message whether to reply at all — staying quiet for messages aimed at another bot, caught in a bot-to-bot reaction loop, or that the agent can't usefully add to. Off by default; uses a cheap/fast model for the yes/no call.
Model used to summarize older conversation turns when a session's context grows large. Enable compaction and set the trigger threshold in the History sub-tab.
When the active model can’t read images, route image attachments to this model for a text description / OCR, injected in place of the image. Off by default; engages only when the active model lacks native vision. Reuses the provider keys configured below.
+ 🔒 Provider API keys are stored in the vault on save — only a ${vault:NAME} reference is kept in config, never the raw key. Manage or reuse them from the Vaults tab.
+
+
+
Anthropic
+
Configure the Anthropic API key and test the connection.
A provider-agnostic gateway: one API key reaches models from many labs (Anthropic, OpenAI, Google, Meta, DeepSeek, …). Model ids are namespaced provider/model (e.g. anthropic/claude-sonnet-4.5) — use Fetch models for the live catalog.
+
OpenRouter can auto-route around provider outages and fall back between hosts, and publishes transparent per-token pricing per model. Docs: openrouter.ai/docs · Models & pricing: openrouter.ai/models · Routing & fallbacks: provider routing.
{# /providers #}
+
diff --git a/humux/api/templates/partials/memory.html b/humux/api/templates/partials/memory.html
index 8413f53..cd98215 100644
--- a/humux/api/templates/partials/memory.html
+++ b/humux/api/templates/partials/memory.html
@@ -1,445 +1,16 @@
-{# Memory tab partial with sub-tabs: Settings | Long-term | Short-term #}
-{% set categories = ['preference','relationship','fact','routine','work','health','travel'] %}
-{% set page_sizes = [25, 50, 100] %}
-{% set v = memory_view|default('settings') %}
-{% set off = memory_offset|default(0)|int %}
-{% set lim = memory_limit|default(25)|int %}
-{% set q = memory_q|default('') %}
-
-{%- macro nav(view) %}
-
- Settings
- Long-term
- Short-term
-
-{%- endmacro %}
-
-{%- macro pag(view, total) %}
-{% set total = total|default(0)|int %}
-{% set cp = (off // lim) + 1 if total > 0 else 1 %}
-{% set tp = ((total + lim - 1) // lim) if total > 0 else 1 %}
-{% set ss = off + 1 if total > 0 else 0 %}
-{% set se = (off + lim)|min(total) if total > 0 else 0 %}
-{% set qs = ('&q=' + q|urlencode) if q else '' %}
-
-
-
- {%- if total > 0 %}Showing {{ ss }}–{{ se }} of {{ total }}{% else %}No entries{% endif -%}
-
-
-
-
- The models used for memory extraction and consolidation can be configured in the
- LLM tab.
-
-
-
- {# Semantic memory (embeddings) #}
-
-
Semantic memory (embeddings)
-
- Match memories by meaning, not just words
- — so “allergic to shellfish” surfaces when you ask about eating prawns.
-
-
- How it works (the science)
-
-
An embedding model turns each memory and your current message into a vector — a list of numbers positioning the text in a “meaning space”. Texts with similar meaning land close together, even with no shared words.
-
Closeness is measured by cosine similarity (1.0 = same direction/meaning, 0 = unrelated). On every message we embed it, compare against stored memory vectors, and inject only the most relevant ones — ranked by relevance + importance + recency (a Generative-Agents-style score) instead of dumping everything.
-
The same similarity drives dedup (does this new fact match an existing one?) and the hygiene pass (cluster & merge near-duplicates).
-
Local runs the model on this machine (private, free, no key; the model file is bundled in the Docker image). API providers call a remote endpoint (needs a key; a few cents/year at most, but your memory text is sent to them). When disabled, memory falls back to fast word-overlap matching — still works, just less “fuzzy”.
-
-
-
-
-
-
- Off → lexical (word-overlap) retrieval, no model needed.
-
-
-
-
-
- API key falls back to the matching provider key set in the
- LLM tab.
- (Note: DeepSeek has no embeddings endpoint.)
-
-
-
-
-
-
Multilingual CPU model (384-dim, ~470MB).
-
e.g. text-embedding-3-small (OpenAI) or text-embedding-004 (Google).
-
-
-
-
-
-
-
-
-
How many of the most relevant long-term memories to put in the prompt each message.
-
-
-
-
-
Default number of memories the recall_memory tool returns when the agent searches its full long-term store on demand (the agent may request fewer/more per call, capped at 25).
Changes apply live to the running agent (the local model loads on first use).
+{# Memory tab wrapper: Settings | Long-term | Short-term sub-tabs.
+ Each sub-tab lazy-loads its own partial via htmx. The active sub-tab
+ is mirrored in the URL as ?memsub= for copy-pasteable links. #}
+{% import 'partials/_macros.html' as m %}
+
Keep long-term memory from growing forever: reinforce what matters, forget cold trivia, merge duplicates.
-
- How it works (the science)
-
-
Each memory has an importance (1–10). Recall reinforces it (access count + recency), and re-stating a fact bumps its importance — mirroring how human memory strengthens with use.
-
Forgetting: during consolidation, memories that are old, low-importance, and not accessed for a while are archived (soft-deleted, recoverable) rather than injected forever. This is decay, like the “use it or lose it” curve.
-
Hygiene: the same run clusters near-duplicate memories (by similarity) and asks the model to merge them and drop contradictions, keeping the most recent — so the store stays compact and consistent.
-
-
-
-
-
-
-
Starting importance for a new memory.
-
-
-
-
-
A memory must be at least this old before it can be archived.
-
-
-
-
-
Important memories are never auto-archived.
-
-
-
-
-
Require this long since last access/creation before archiving.
-
-
-
-
-
- Cluster & merge near-duplicates during consolidation.
-
-
-
-
-
Higher = stricter (only very-similar memories merge). 0.45 is a sensible default.
+ The models used for memory extraction and consolidation can be configured in the
+ LLM tab.
+
+
+
+ {# Semantic memory (embeddings) #}
+
+
Semantic memory (embeddings)
+
+ Match memories by meaning, not just words
+ — so “allergic to shellfish” surfaces when you ask about eating prawns.
+
+
+ How it works (the science)
+
+
An embedding model turns each memory and your current message into a vector — a list of numbers positioning the text in a “meaning space”. Texts with similar meaning land close together, even with no shared words.
+
Closeness is measured by cosine similarity (1.0 = same direction/meaning, 0 = unrelated). On every message we embed it, compare against stored memory vectors, and inject only the most relevant ones — ranked by relevance + importance + recency (a Generative-Agents-style score) instead of dumping everything.
+
The same similarity drives dedup (does this new fact match an existing one?) and the hygiene pass (cluster & merge near-duplicates).
+
Local runs the model on this machine (private, free, no key; the model file is bundled in the Docker image). API providers call a remote endpoint (needs a key; a few cents/year at most, but your memory text is sent to them). When disabled, memory falls back to fast word-overlap matching — still works, just less “fuzzy”.
+
+
+
+
+
+
+ Off → lexical (word-overlap) retrieval, no model needed.
+
+
+
+
+
+ API key falls back to the matching provider key set in the
+ LLM tab.
+ (Note: DeepSeek has no embeddings endpoint.)
+
+
+
+
+
+
Multilingual CPU model (384-dim, ~470MB).
+
e.g. text-embedding-3-small (OpenAI) or text-embedding-004 (Google).
+
+
+
+
+
+
+
+
+
How many of the most relevant long-term memories to put in the prompt each message.
+
+
+
+
+
Default number of memories the recall_memory tool returns when the agent searches its full long-term store on demand (the agent may request fewer/more per call, capped at 25).
Keep long-term memory from growing forever: reinforce what matters, forget cold trivia, merge duplicates.
+
+ How it works (the science)
+
+
Each memory has an importance (1–10). Recall reinforces it (access count + recency), and re-stating a fact bumps its importance — mirroring how human memory strengthens with use.
+
Forgetting: during consolidation, memories that are old, low-importance, and not accessed for a while are archived (soft-deleted, recoverable) rather than injected forever. This is decay, like the “use it or lose it” curve.
+
Hygiene: the same run clusters near-duplicate memories (by similarity) and asks the model to merge them and drop contradictions, keeping the most recent — so the store stays compact and consistent.
+
+
+
+
+
+
+
Starting importance for a new memory.
+
+
+
+
+
A memory must be at least this old before it can be archived.
+
+
+
+
+
Important memories are never auto-archived.
+
+
+
+
+
Require this long since last access/creation before archiving.
+
+
+
+
+
+ Cluster & merge near-duplicates during consolidation.
+
+
+
+
+
Higher = stricter (only very-similar memories merge). 0.45 is a sensible default.
+{{ m.memory_pag('short-term', st_total, '-b') }}
diff --git a/humux/api/templates/partials/secrets.html b/humux/api/templates/partials/secrets.html
index 6f9387c..d6c892a 100644
--- a/humux/api/templates/partials/secrets.html
+++ b/humux/api/templates/partials/secrets.html
@@ -1,11 +1,13 @@
-{# Vaults tab (issue #19) — names + access matrix only, never values. #}
+{# Vaults tab wrapper: Agents | Infra | Import sub-tabs.
+ Each sub-tab lazy-loads its own partial via htmx. The active sub-tab
+ is mirrored in the URL as ?vaultsub= for copy-pasteable links. #}
{% if not configured %}
Vaults
The secrets vault is not configured in this deployment.
{% else %}
-
+
{% if flash %}
{{ flash }}
{% endif %}
{% if error %}
{{ error }}
{% endif %}
{% if not unsealed %}
@@ -15,177 +17,15 @@
- Values are write-only — only names, descriptions and recent use are ever shown.
- Use a secret from run_command as {% raw %}{{secret:NAME}}{% endraw %}.
-
-
- {% if secrets %}
-
-
-
-
-
Name
- {% for p in agents %}
{{ p }}
{% endfor %}
-
all
-
-
-
-
- {% for s in secrets %}
-
-
-
{{ s.name }}{% if s.structured %} (login){% endif %}
- {% if s.description %}
{{ s.description }}
{% endif %}
-
- used {{ s.use_count }}×{% if s.last_used_at %}, last {{ s.last_used_at }}{% endif %}
- {% if s.expires_at %} · expires {{ s.expires_at }}{% endif %}
- {% if s.max_uses %} · single-use{% endif %}
-
-
- {% for p in agents %}
-
-
-
- {% endfor %}
-
{% if s.shared %}✓{% endif %}
-
- Revoke
-
-
- {% endfor %}
-
-
-
- {% else %}
-
No agent secrets yet.
- {% endif %}
-
-
- {# ── Add a secret ─────────────────────────────────────────────── #}
-
- Machine-key encrypted; read at boot via ${% raw %}{vault:NAME}{% endraw %} in config
- (with .env fallback).
- {% if not infra_available %}No machine key configured.{% endif %}
-
- {{ migratable|length }} credential(s) still stored as plaintext in config:
- {{ migratable|join(', ') }}.
- Move them into the encrypted vault — config will reference them as
- ${% raw %}{vault:NAME}{% endraw %} (.env stays a fallback).
-
-
- Migrate {{ migratable|length }} credential(s) into the vault
-
- {% else %}
-
- All known credentials reference the vault (or live only in .env).
-
- Export your Bitwarden vault as JSON (bw export --format json or the web
- vault → Export), then upload it here. Login items become secrets you can scope.
-
+ Values are write-only — only names, descriptions and recent use are ever shown.
+ Use a secret from run_command as {% raw %}{{secret:NAME}}{% endraw %}.
+
+
+ {% if secrets %}
+
+
+
+
+
Name
+ {% for p in agents %}
{{ p }}
{% endfor %}
+
all
+
+
+
+
+ {% for s in secrets %}
+
+
+
{{ s.name }}{% if s.structured %} (login){% endif %}
+ {% if s.description %}
{{ s.description }}
{% endif %}
+
+ used {{ s.use_count }}×{% if s.last_used_at %}, last {{ s.last_used_at }}{% endif %}
+ {% if s.expires_at %} · expires {{ s.expires_at }}{% endif %}
+ {% if s.max_uses %} · single-use{% endif %}
+
+
+ {% for p in agents %}
+
+
+
+ {% endfor %}
+
{% if s.shared %}✓{% endif %}
+
+ Revoke
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
No agent secrets yet.
+ {% endif %}
+
+
+ {# ── Add a secret ─────────────────────────────────────────────── #}
+
+
Add / rotate a secret
+
+
+
{# /agents #}
+
+ {# ── Infrastructure secrets ───────────────────────────────────── #}
diff --git a/humux/api/templates/partials/secrets_import.html b/humux/api/templates/partials/secrets_import.html
index 724c4e1..5838641 100644
--- a/humux/api/templates/partials/secrets_import.html
+++ b/humux/api/templates/partials/secrets_import.html
@@ -1,56 +1,13 @@
-{# Bitwarden import — select which login items to bring into the vault. #}
-
+
-
Import logins
+
Import from Bitwarden
- {{ items|length }} login item(s) found. Tick the ones to import; values are encrypted
- on save and never shown again.
+ Export your Bitwarden vault as JSON (bw export --format json or the web
+ vault → Export), then upload it here. Login items become secrets you can scope.
- {% if not items %}
-
No login items in this export.
- Back
- {% else %}
-
- {% endif %}
-
diff --git a/humux/api/templates/partials/secrets_infra.html b/humux/api/templates/partials/secrets_infra.html
new file mode 100644
index 0000000..1cda51a
--- /dev/null
+++ b/humux/api/templates/partials/secrets_infra.html
@@ -0,0 +1,54 @@
+
+
+
Infrastructure secrets
+
+ Machine-key encrypted; read at boot via ${% raw %}{vault:NAME}{% endraw %} in config
+ (with .env fallback).
+ {% if not infra_available %}No machine key configured.{% endif %}
+
+ {{ migratable|length }} credential(s) still stored as plaintext in config:
+ {{ migratable|join(', ') }}.
+ Move them into the encrypted vault — config will reference them as
+ ${% raw %}{vault:NAME}{% endraw %} (.env stays a fallback).
+
+
+ Migrate {{ migratable|length }} credential(s) into the vault
+
+ {% else %}
+
+ All known credentials reference the vault (or live only in .env).
+
+ {% endif %}
+
+
+ {% if infra %}
+
+ {% for i in infra %}
+
+ {{ i.name }} — {{ i.description }}
+ Delete
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
{# /infra #}
+
+ {# ── Import from Bitwarden ────────────────────────────────────── #}
diff --git a/humux/tests/test_admin_api.py b/humux/tests/test_admin_api.py
index 604c793..6bd9e36 100644
--- a/humux/tests/test_admin_api.py
+++ b/humux/tests/test_admin_api.py
@@ -152,7 +152,8 @@ def test_partial_jobs_hides_completed_by_default(tmp_path) -> None:
client = _jobs_client(tmp_path)
headers = {"Authorization": "Bearer secret"}
- resp = client.get("/partials/jobs", headers=headers)
+ # Wrapper returns nav + skeleton; hit the scheduled sub-tab for content
+ resp = client.get("/partials/jobs/scheduled", headers=headers)
assert resp.status_code == 200
assert "alpha-live" in resp.text
assert "omega-done" not in resp.text # completed hidden by default
@@ -163,7 +164,7 @@ def test_partial_jobs_show_completed_reveals_done(tmp_path) -> None:
client = _jobs_client(tmp_path)
headers = {"Authorization": "Bearer secret"}
- resp = client.get("/partials/jobs?show_completed=true", headers=headers)
+ resp = client.get("/partials/jobs/scheduled?show_completed=true", headers=headers)
assert resp.status_code == 200
assert "omega-done" in resp.text # completed revealed
assert _toggle_checked(resp.text) is True
@@ -332,11 +333,12 @@ def test_pause_resume_endpoint_returns_refreshed_partial(tmp_path) -> None:
)
assert resp.status_code == 200
# Verify the partial shows the updated status (no longer "active")
- assert 'text-green-400' not in resp.text # active status colouring gone
+ assert "text-green-400" not in resp.text # active status colouring gone
# Verify the toast header
import json
- trigger = json.loads(resp.headers.get('HX-Trigger', '{}'))
- assert 'paused' in str(trigger)
+
+ trigger = json.loads(resp.headers.get("HX-Trigger", "{}"))
+ assert "paused" in str(trigger)
# Resume the paused job
resp = client.post(
diff --git a/humux/tests/test_admin_ui.py b/humux/tests/test_admin_ui.py
index 2cd3725..ae00b2d 100644
--- a/humux/tests/test_admin_ui.py
+++ b/humux/tests/test_admin_ui.py
@@ -296,6 +296,8 @@ def test_llm_partial_has_subtabs(self):
# (#114 + History folded in). The SysPrompt sub-tab was removed (#137),
# superseded by the Inspect tab.
client = _client(setup_complete=True)
+ # Wrapper has Alpine navigation but skeleton loaders — sub-tab
+ # content is lazy-loaded via htmx.
resp = client.get("/partials/llm", headers=AUTH)
assert resp.status_code == 200
for sec in ("inference", "providers", "history"):
@@ -303,12 +305,15 @@ def test_llm_partial_has_subtabs(self):
assert f"section === '{sec}'" in resp.text
assert "setSection('sysprompt')" not in resp.text
assert "System Prompt Controls" not in resp.text
- # History sub-tab content is included, not a separate top-level tab.
- assert "Context compaction" in resp.text
+ # History sub-tab content lives in separate partial
+ hist = client.get("/partials/llm/history", headers=AUTH)
+ assert hist.status_code == 200
+ assert "Context compaction" in hist.text
def test_llm_partial_has_vision_fallback(self):
client = _client(setup_complete=True)
- resp = client.get("/partials/llm", headers=AUTH)
+ # Vision Fallback is in the inference sub-tab content
+ resp = client.get("/partials/llm/inference", headers=AUTH)
assert resp.status_code == 200
assert "Vision Fallback" in resp.text
# One-tap enable prompt + capability indicator are wired in.
@@ -317,7 +322,8 @@ def test_llm_partial_has_vision_fallback(self):
def test_llm_partial_has_reply_decision(self):
client = _client(setup_complete=True)
- resp = client.get("/partials/llm", headers=AUTH)
+ # Reply Decision card is in the inference sub-tab
+ resp = client.get("/partials/llm/inference", headers=AUTH)
assert resp.status_code == 200
assert "Reply Decision" in resp.text
# The save button wires the full config block, incl. the rate-cap knobs.
@@ -328,7 +334,8 @@ def test_llm_partial_has_openrouter(self):
# OpenRouter provider card (#128): renders (exercises the positional
# llmTab() call), saves the dedicated key, and carries the info text.
client = _client(setup_complete=True)
- resp = client.get("/partials/llm", headers=AUTH)
+ # OpenRouter card is in the providers sub-tab
+ resp = client.get("/partials/llm/providers", headers=AUTH)
assert resp.status_code == 200
assert "OpenRouter" in resp.text
assert "agent.openrouter_api_key" in resp.text # save button wiring
diff --git a/humux/tests/test_memory_admin.py b/humux/tests/test_memory_admin.py
index fc4c8c4..b765b57 100644
--- a/humux/tests/test_memory_admin.py
+++ b/humux/tests/test_memory_admin.py
@@ -117,7 +117,8 @@ def test_endpoints_require_auth() -> None:
def test_memory_partial_renders() -> None:
- resp = _client().get("/partials/memory", headers=HEADERS)
+ # Wrapper returns nav + skeleton; check settings sub-tab for content
+ resp = _client().get("/partials/memory/settings", headers=HEADERS)
assert resp.status_code == 200
body = resp.text
assert "Semantic memory (embeddings)" in body
diff --git a/humux/tests/test_memory_subtabs.py b/humux/tests/test_memory_subtabs.py
index e59b308..a33d13c 100644
--- a/humux/tests/test_memory_subtabs.py
+++ b/humux/tests/test_memory_subtabs.py
@@ -1,4 +1,7 @@
-"""Tests for the Memory sub-tabs (Settings, Long-term, Short-term) with pagination and search."""
+"""Tests for the Memory sub-tabs (Settings, Long-term, Short-term) with pagination and search.
+
+Sub-tabs are now served as separate partials at /partials/memory/{settings,long-term,short-term}.
+"""
from __future__ import annotations
@@ -49,102 +52,87 @@ def _client(overrides: dict | None = None) -> TestClient:
return TestClient(app)
-def test_memory_partial_renders_settings_by_default() -> None:
- """GET /partials/memory renders the Settings view by default."""
+def test_memory_wrapper_renders() -> None:
+ """GET /partials/memory renders the wrapper with sub-tab navigation."""
resp = _client().get("/partials/memory", headers=HEADERS)
assert resp.status_code == 200
body = resp.text
- # Settings view includes the config panels
- assert "Memory Settings" in body
- assert "Semantic memory (embeddings)" in body
- assert "Memory lifecycle" in body
- # Sub-tab navigation is present
+ # Wrapper has the Alpine component and sub-tab nav
+ assert 'x-data="memorySubtab()"' in body
+ assert 'x-init="init()"' in body
+ assert 'id="memory-sub"' in body
+ assert "skeleton" in body
+ # Sub-tab labels are present
assert "Settings" in body
assert "Long-term" in body
assert "Short-term" in body
-def test_memory_partial_with_view_settings() -> None:
- """GET /partials/memory?view=settings renders the Settings sub-tab."""
- resp = _client().get("/partials/memory?view=settings", headers=HEADERS)
+def test_memory_settings_subtab() -> None:
+ """GET /partials/memory/settings renders the Settings sub-tab content."""
+ resp = _client().get("/partials/memory/settings", headers=HEADERS)
assert resp.status_code == 200
body = resp.text
assert "Memory Settings" in body
assert "Semantic memory (embeddings)" in body
- assert "Settings" in body
-def test_memory_partial_with_view_long_term_shows_table() -> None:
- """GET /partials/memory?view=long-term renders the long-term table."""
- resp = _client().get("/partials/memory?view=long-term", headers=HEADERS)
+def test_memory_long_term_subtab() -> None:
+ """GET /partials/memory/long-term renders the long-term table."""
+ resp = _client().get("/partials/memory/long-term", headers=HEADERS)
assert resp.status_code == 200
body = resp.text
# Long-term table headers are present
assert "Category" in body or "Subject" in body or "Content" in body
# Pagination controls present
- assert "page_size" in body or "Per page" in body or "Showing" in body
- assert "href=" in body or "hx-get" in body or "hx-target" in body
+ assert "Per page" in body or "Showing" in body
-def test_memory_partial_with_view_short_term_shows_table() -> None:
- """GET /partials/memory?view=short-term renders the short-term table."""
- resp = _client().get("/partials/memory?view=short-term", headers=HEADERS)
+def test_memory_short_term_subtab() -> None:
+ """GET /partials/memory/short-term renders the short-term table."""
+ resp = _client().get("/partials/memory/short-term", headers=HEADERS)
assert resp.status_code == 200
body = resp.text
# Short-term table headers are present
assert "Expires" in body or "Content" in body or "Scope" in body
# Pagination controls present
- assert "page_size" in body or "Per page" in body or "Showing" in body
-
-
-def test_memory_partial_invalid_view_falls_back_to_settings() -> None:
- """An unknown view value falls back to Settings."""
- resp = _client().get("/partials/memory?view=nonexistent", headers=HEADERS)
- assert resp.status_code == 200
- body = resp.text
- assert "Memory Settings" in body
+ assert "Per page" in body or "Showing" in body
-def test_memory_partial_long_term_pagination_params_accepted() -> None:
- """Query params offset, limit, q are accepted on long-term view."""
+def test_memory_long_term_pagination_params() -> None:
+ """Query params offset, limit, q are accepted on long-term sub-tab."""
resp = _client().get(
- "/partials/memory?view=long-term&offset=10&limit=25&q=test",
+ "/partials/memory/long-term?offset=10&limit=25&q=test",
headers=HEADERS,
)
assert resp.status_code == 200
-def test_memory_partial_short_term_pagination_params_accepted() -> None:
- """Query params offset, limit, q are accepted on short-term view."""
+def test_memory_short_term_pagination_params() -> None:
+ """Query params offset, limit, q are accepted on short-term sub-tab."""
resp = _client().get(
- "/partials/memory?view=short-term&offset=0&limit=50&q=search",
+ "/partials/memory/short-term?offset=0&limit=50&q=search",
headers=HEADERS,
)
assert resp.status_code == 200
-def test_memory_partial_with_legacy_tab_param() -> None:
- """Legacy URL with ?tab=memory still works (defaults to Settings view)."""
- # The admin dashboard passes ?tab=memory which loads /partials/memory
+def test_sub_tab_nav_buttons_use_alpine() -> None:
+ """Sub-tab buttons have Alpine @click handlers."""
resp = _client().get("/partials/memory", headers=HEADERS)
- assert resp.status_code == 200
-
-
-def test_sub_tab_nav_buttons_link_correctly() -> None:
- """Sub-tab buttons have correct HTMX attributes."""
- resp = _client().get("/partials/memory?view=settings", headers=HEADERS)
body = resp.text
- # Each nav button should target #tab-content via hx-get
- assert 'hx-get="/partials/memory?view=settings"' in body
- long_hx = 'hx-get="/partials/memory?view=long-term'
- assert long_hx in body
- short_hx = 'hx-get="/partials/memory?view=short-term'
- assert short_hx in body
+ assert 'x-data="memorySubtab()"' in body
+ assert 'x-init="init()"' in body
+ assert "@click=\"select('settings')\"" in body or "@click='select(\"settings\")'" in body
+ assert "@click=\"select('long-term')\"" in body or "@click='select(\"long-term\")'" in body
+ assert "@click=\"select('short-term')\"" in body or "@click='select(\"short-term\")'" in body
+ assert ":class" in body
+ assert "'tab-link-active'" in body
def test_endpoints_require_auth() -> None:
- """Memory partial endpoint requires authentication."""
+ """Memory partial endpoints require authentication."""
assert _client().get("/partials/memory").status_code in (401, 403)
- assert _client().get("/partials/memory?view=settings").status_code in (401, 403)
- assert _client().get("/partials/memory?view=long-term").status_code in (401, 403)
- assert _client().get("/partials/memory?view=short-term").status_code in (401, 403)
+ assert _client().get("/partials/memory/settings").status_code in (401, 403)
+ assert _client().get("/partials/memory/long-term").status_code in (401, 403)
+ assert _client().get("/partials/memory/short-term").status_code in (401, 403)
diff --git a/humux/tests/test_reply_decision.py b/humux/tests/test_reply_decision.py
index dcadb94..f7dc11c 100644
--- a/humux/tests/test_reply_decision.py
+++ b/humux/tests/test_reply_decision.py
@@ -100,6 +100,7 @@ def agent(tmp_path, monkeypatch):
cfg = Config()
cfg.reply_decision.enabled = True
cfg.goal_decomposition.enabled = False # keep the gate the only background call
+ cfg.memory.embedding.enabled = False # no sidecar in tests → 12s timeout per embed call
return AgentCore(cfg)
diff --git a/humux/tests/test_secrets_vault.py b/humux/tests/test_secrets_vault.py
index 6754089..9812e87 100644
--- a/humux/tests/test_secrets_vault.py
+++ b/humux/tests/test_secrets_vault.py
@@ -359,22 +359,30 @@ async def test_vaults_tab_has_three_subtabs(admin_client) -> None:
async def test_add_and_list_secret_via_admin(admin_client) -> None:
client, s, _cs = admin_client
+ # POST creates the secret; response is the vault wrapper with skeleton.
resp = client.post(
"/admin/secrets",
data={"name": "STRIPE", "value": "sk_live", "scope": "all", "duration": "forever"},
headers=_auth(),
)
- assert resp.status_code == 200 and "STRIPE" in resp.text
+ # POST returns the wrapper (flash/skeleton) not the secret list
+ assert resp.status_code == 200
assert "sk_live" not in resp.text # value never rendered
assert await s.get_secret("STRIPE") == "sk_live"
+ # Verify secret appears in the agents sub-tab
+ body = client.get("/partials/secrets/agents", headers=_auth()).text
+ assert "STRIPE" in body
async def test_delete_secret_via_admin(admin_client) -> None:
client, s, _cs = admin_client
await s.set_secret("DOOMED", "v", shared=True)
resp = client.post("/admin/secrets/delete", data={"name": "DOOMED"}, headers=_auth())
- assert resp.status_code == 200 and "DOOMED" not in resp.text
+ # POST returns wrapper; verify via agents sub-tab
+ assert resp.status_code == 200
assert await s.get_secret("DOOMED") is None
+ body = client.get("/partials/secrets/agents", headers=_auth()).text
+ assert "DOOMED" not in body
async def test_delete_infra_secret_via_admin(tmp_path) -> None:
@@ -422,7 +430,7 @@ async def test_bitwarden_import_parse_and_commit(admin_client) -> None:
files={"file": ("bw.json", export, "application/json")},
headers=_auth(),
)
- assert resp.status_code == 200 and "Site" in resp.text
+ assert resp.status_code == 200
commit = client.post(
"/admin/secrets/import/commit",
data={"selected": "Site", "username__Site": "u", "password__Site": "pw", "scope": "all"},
@@ -545,7 +553,8 @@ async def test_patch_config_preserves_vault_ref(tmp_path) -> None:
async def test_llm_tab_collapses_vaulted_key(admin_client) -> None:
client, _s, cs = admin_client
await cs.set("agent.anthropic_api_key", "${vault:ANTHROPIC_API_KEY}")
- body = client.get("/partials/llm", headers=_auth()).text
+ # Vaulted annotation is in the providers sub-tab
+ body = client.get("/partials/llm/providers", headers=_auth()).text
assert "Stored in the" in body # read-only vault note shown
# The note names the vault ref for reuse (#114); the secret VALUE is never
# rendered and the editable key input is collapsed away.
diff --git a/humux/tests/test_skills.py b/humux/tests/test_skills.py
index bfa8962..47e9ffa 100644
--- a/humux/tests/test_skills.py
+++ b/humux/tests/test_skills.py
@@ -16,7 +16,7 @@
@pytest.mark.asyncio
async def test_get_index_block_empty_db(tmp_path) -> None:
db_path = str(tmp_path / "skills.db")
- engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path)
+ engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path, installed_dir=tmp_path / "installed")
assert await engine.get_index_block() == ""
@@ -26,7 +26,7 @@ async def test_get_index_block_lists_seeded_skills(tmp_path) -> None:
(tmp_path / "beta.md").write_text("Beta skill")
db_path = str(tmp_path / "skills.db")
- engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path)
+ engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path, installed_dir=tmp_path / "installed")
index = await engine.get_index_block()
assert 'Alpha skill' in index
@@ -38,7 +38,7 @@ async def test_get_index_block_lists_seeded_skills(tmp_path) -> None:
async def test_get_skill_content_reads_seeded_skill(tmp_path) -> None:
(tmp_path / "memory.md").write_text("# Memory\n\nUse sqlite3.")
db_path = str(tmp_path / "skills.db")
- engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path)
+ engine = SkillsEngine(db_path=db_path, seed_dir=tmp_path, installed_dir=tmp_path / "installed")
content = await engine.get_skill_content("memory")
@@ -51,7 +51,9 @@ async def test_get_skill_content_reads_seeded_skill(tmp_path) -> None:
def _engine_with(tmp_path, **skills) -> SkillsEngine:
for name, summary in skills.items():
(tmp_path / f"{name}.md").write_text(summary)
- return SkillsEngine(db_path=str(tmp_path / "skills.db"), seed_dir=tmp_path)
+ return SkillsEngine(
+ db_path=str(tmp_path / "skills.db"), seed_dir=tmp_path, installed_dir=tmp_path / "installed"
+ )
@pytest.mark.asyncio