Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison a44776b18e fix: surface AC-4 opt-in on cross-origin CORS block in gateway probe
The reachability probe used default cors mode, so a cross-origin CORS
block rejected the fetch (landing in the 'network' branch) instead of
producing an opaque response. That made the 'opaque' branch and its
"Continue anyway" checkbox (AC-4) dead code. Now a rejected fetch on a
cross-origin URL maps to 'opaque' (ambiguous: CORS-blocked or down) so
the opt-in checkbox renders, while same-origin rejections remain a
definitive 'network' error.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 10:56:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 92adfc6f6e fix: honest gateway reachability probe on onboarding Screen 1 (fixes #210)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 06:32:04 +00:00
MervinPraison 90315326f7 Release v0.3.138 2026-07-30 01:56:27 +00:00
MervinPraison 0719c41399 fix: auto-trigger conflict and CI fixes without FINAL review gate
Remove the FINAL @claude prerequisite from merge-conflict and CI-failure
workflows, clear pipeline/blocked:conflict after rebase completion, add
bot-pr-recovery cron, and update gate selftests.
2026-07-29 18:15:42 +01:00
praisonai-triage-agent[bot] d008b6a250 Merge pull request #216 from MervinPraison/claude/issue-208-20260714-i18n-memory
feat: wire i18n strings and per-turn memory search into context ring UI
2026-07-29 14:38:03 +00:00
MervinPraisonandClaude 631ae74a29 feat: wire i18n strings and per-turn memory search into context ring UI (fixes #208)
Gap A: chat.js now loads locale strings via /api/i18n and renders ring
tooltip, compaction banner, compacted flash, and memory chip through a
t() helper with English fallback.

Gap B: refreshMemoryChip() posts the last user message to
/api/memory/search and shows per-turn hit count instead of the total
stored memory count from GET /api/memory.

Gap C: role="progressbar" and aria-value* moved to the ring wrapper div;
the send button is now type="button" with a send-only label.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 13:05:17 +00:00
praisonai-triage-agent[bot] e85f6778dd Merge pull request #258 from MervinPraison/claude/issue-256-20260729-120000
fix: stop full-suite pollution flaking auth timing + health cache tests
2026-07-29 11:39:19 +00:00
praisonai-triage-agent[bot] d6354fade0 Merge pull request #257 from MervinPraison/claude/issue-255-20260729-000000
fix: Windows-safe test_default_sqlite_url (stub HOME/USERPROFILE)
2026-07-29 11:35:43 +00:00
MervinPraisonandClaude b16a5f6056 fix: stop full-suite pollution flaking auth timing + health cache tests (fixes #256)
Reset the global _features registry in the test_server client fixture so a
feature leaked by an earlier crashing test cannot slow /health and flake the
deep-health cache/timing assertions. Make test_timing_attack_resistance
noise-resistant by comparing the median of repeated samples with a warm-up
instead of a single sample, so CPU contention under the full suite no longer
trips the variance threshold.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 09:28:32 +00:00
MervinPraisonandClaude 6521beb7b2 fix: stub HOME/USERPROFILE in test_default_sqlite_url for Windows (fixes #255)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 09:21:35 +00:00
MervinPraison 9e3384c40a Release v0.3.137 2026-07-29 02:06:56 +00:00
praisonai-triage-agent[bot] e12416e15b Merge pull request #254 from MervinPraison/claude/issue-252-20260728-000000
fix: make five unit tests pass on Windows
2026-07-28 11:38:05 +00:00
MervinPraison 5568b10077 Release v0.3.136 2026-07-28 11:33:51 +00:00
praisonai-triage-agent[bot] 557852b287 Merge pull request #253 from MervinPraison/claude/issue-251-20260728-000000
fix: make URL-token auth opt-in so env token no longer forces 401
2026-07-28 11:30:52 +00:00
MervinPraisonandClaude 652d612d35 fix: make five unit tests pass on Windows (fixes #252)
Resolve cross-platform failures caused by Windows' coarse time.time()
resolution, backslash path separators, and reliance on the grep binary.

- lifecycle: measure hook durations with time.perf_counter() so
  startup_time/shutdown_time are non-zero on Windows' low-res wall clock
- datastore: build the default sqlite URL with Path.as_posix() for a
  consistent forward-slash URL across platforms
- test_audio_hooks: allow last_chunk_at == started_at (clock granularity)
- test_sqlalchemy_datastore: compare against as_posix() paths
- test_unified_naming: replace grep subprocess with a portable Python scan

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-28 10:17:52 +00:00
MervinPraisonandClaude 1f578a4827 fix: make URL-token auth opt-in so env token no longer forces 401 (fixes #251)
TokenQueryMiddleware previously activated whenever GATEWAY_AUTH_TOKEN or
AIUI_URL_TOKEN was present in the process environment, forcing 401 on all
standalone /api/* routes. A token leaking into a developer shell after a
gateway run broke ad-hoc TestClient scripts (#238, #251).

Activation now requires explicit opt-in: AIUI_REQUIRE_TOKEN, YAML
auth.requireToken, or require_auth=True. Secured deployments keep full
Bearer / query-param / cookie token auth.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-28 10:16:03 +00:00
praisonai-triage-agent[bot] 84ebdbc695 Merge pull request #247 from MervinPraison/claude/issue-236-20260727-0911
fix: add UTF-8 encoding to frontend source test reads
2026-07-27 12:23:34 +00:00
praisonai-triage-agent[bot] 182d065524 Merge pull request #246 from MervinPraison/claude/issue-239-20260727-120000
fix: correct tracing integration test paths to /api/traces
2026-07-27 12:18:36 +00:00
praisonai-triage-agent[bot] 132018d123 Merge pull request #245 from MervinPraison/claude/issue-237-20260727-0911
fix: use explicit UTF-8 encoding in CLI integration test fixture
2026-07-27 12:13:41 +00:00
MervinPraisonandClaude 0cc7f70bbb fix: correct tracing integration test paths to /api/traces (fixes #239)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-27 09:14:24 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 87c9ed09b4 fix: add UTF-8 encoding to frontend source test reads (fixes #236)
Standardize Path.read_text() calls across unit/integration tests to
explicitly use encoding="utf-8". On Windows the default locale is cp1252,
which raises UnicodeDecodeError when reading committed UTF-8 frontend
bundles (em dash, emoji, multi-byte chars), producing false negatives.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-27 09:14:23 +00:00
praisonai-triage-agent[bot]andMervin Praison 30c34e7266 fix: use explicit UTF-8 encoding in CLI integration test fixture (fixes #237)
Test fixture wrote YAML/markdown and read JSON without encoding, defaulting
to cp1252 on Windows and breaking UTF-8 reads for the © character. Specify
encoding="utf-8" on all write_text/read_text calls for deterministic
cross-platform behaviour.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:13:28 +00:00
22 changed files with 512 additions and 100 deletions

No files matched your search

@@ -32,4 +32,13 @@ const comment = ciFix.buildCiFixComment({
});
assert('comment mentions product guardrails', comment.includes('Product guardrails'));
const skip = ciFix.shouldSkipCiFix({
comments: [],
headSha: 'abc1234567890abcdef1234567890abcdef12',
labels: [],
claudeInProgress: false,
failedChecks: [{ name: 'python', workflow: 'CI' }],
});
assert('ci fix without FINAL review allowed', !skip.skip);
process.exit(failed ? 1 : 0);
+1 -4
View File
@@ -46,10 +46,9 @@ function hasRecentCiFixComment(comments, headSha) {
});
}
function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
function shouldSkipCiFix({ comments, headSha, labels, claudeInProgress, failedChecks }) {
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
if (hasCiFixCommentForSha(comments, headSha)) {
return { skip: true, reason: 'already commented for this sha' };
@@ -221,7 +220,6 @@ async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts
return { skipped: true, reason: 'no failed checks yet (may be pending)' };
}
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(ctx.comments);
const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant(
github, owner, repo, prNumber
);
@@ -229,7 +227,6 @@ async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts
comments: ctx.comments,
headSha,
labels,
hasFinal,
claudeInProgress,
failedChecks: failedRuns,
});
+1 -1
View File
@@ -205,7 +205,7 @@ const headAfterRebase = conflictIso(-21 * 60 * 1000);
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
assert('conflict clears after rebase without FINAL on HEAD', !mg.hasRecentConflictComment(
[conflictTrigger, rebaseDone],
headAfterRebase
));
+1 -1
View File
@@ -113,7 +113,7 @@ function conflictRebaseQuiescent(comments, headPushedAt) {
);
if (!rebaseDone) return false;
return finalClaudeCompletedOnSha(comments, headPushedAt);
return true;
}
function hasRecentConflictComment(comments, headPushedAt = null) {
+43
View File
@@ -0,0 +1,43 @@
name: Bot PR Recovery
# Belt-and-braces: kick CodeRabbit/Qodo when pull_request:opened never fired for bot PRs.
on:
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
inputs:
pr_number:
description: 'Specific PR number to kick (optional)'
required: false
type: string
permissions:
pull-requests: write
issues: write
jobs:
recover:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Find and kick stalled bot PRs
uses: actions/github-script@v7
env:
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const manual = (process.env.PR_NUMBER_INPUT || '').trim();
const prNumber = manual ? parseInt(manual, 10) : null;
if (manual && Number.isNaN(prNumber)) {
throw new Error(`Invalid pr_number: ${manual}`);
}
await chain.recoverStalledBotPrs(github, owner, repo, { prNumber }, core);
+1 -15
View File
@@ -12,7 +12,7 @@ on:
- cron: '0 */6 * * *'
concurrency:
group: merge-conflict-scan-${{ github.repository }}
group: merge-conflict-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
env:
@@ -83,16 +83,6 @@ jobs:
};
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some((c) => {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
}
function hasRecentAutoComment(comments) {
const cutoff = Date.now() - COOLDOWN_MS;
return comments.some((c) => {
@@ -143,10 +133,6 @@ jobs:
return { skipped: true, reason: 'recent auto comment' };
}
if (!hasFinalClaudeReviewTrigger(comments)) {
return { skipped: true, reason: 'awaiting final claude review trigger' };
}
await github.rest.issues.addLabels({
owner,
repo,
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.135"
version = "0.3.138"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.135"
__version__ = "0.3.138"
+1 -1
View File
@@ -591,7 +591,7 @@ class SQLAlchemyDataStore(BaseDataStore):
base = Path(os.environ.get("AIUI_DATA_DIR", str(Path.home() / ".praisonaiui")))
base.mkdir(parents=True, exist_ok=True)
db_path = base / "aiui.db"
return f"sqlite+aiosqlite:///{db_path}"
return f"sqlite+aiosqlite:///{db_path.as_posix()}"
async def _ensure_initialized(self) -> None:
"""Lazy initialization of SQLAlchemy engine and tables."""
+4 -4
View File
@@ -175,7 +175,7 @@ async def run_startup_hooks() -> None:
return
_log.info(f"Running {len(_startup_hooks)} startup hooks")
start_time = time.time()
start_time = time.perf_counter()
for hook in _startup_hooks:
try:
@@ -187,7 +187,7 @@ async def run_startup_hooks() -> None:
_log.error(f"Startup hook failed: {getattr(hook, '__name__', str(hook))}: {e}")
# Continue with other hooks - individual failures shouldn't break startup
execution_time = time.time() - start_time
execution_time = time.perf_counter() - start_time
_lifecycle_state.update(
{
"startup_completed": True,
@@ -209,7 +209,7 @@ async def run_shutdown_hooks() -> None:
_lifecycle_state["shutdown_initiated"] = True
_log.info(f"Running {len(_shutdown_hooks)} shutdown hooks")
start_time = time.time()
start_time = time.perf_counter()
# Get shutdown timeout from environment
timeout = float(os.environ.get("AIUI_SHUTDOWN_TIMEOUT", "30.0"))
@@ -227,7 +227,7 @@ async def run_shutdown_hooks() -> None:
try:
await asyncio.wait_for(_execute_hooks(), timeout=timeout)
execution_time = time.time() - start_time
execution_time = time.perf_counter() - start_time
_lifecycle_state["shutdown_time"] = execution_time
_log.info(f"Shutdown hooks completed in {execution_time:.3f}s")
except asyncio.TimeoutError:
+24 -4
View File
@@ -2860,7 +2860,14 @@ def create_app(
require_auth: bool = False,
config_path: Optional[Path] = None,
) -> Starlette:
"""Create the Starlette application."""
"""Create the Starlette application.
URL-token auth (``TokenQueryMiddleware``) is opt-in: it activates only
when ``AIUI_REQUIRE_TOKEN`` is truthy, YAML ``auth.requireToken`` is set,
or ``require_auth=True``. A bare ``GATEWAY_AUTH_TOKEN`` /
``AIUI_URL_TOKEN`` env var (e.g. leaked into a developer shell after a
gateway run) no longer forces 401 on standalone ``/api/*`` (#238, #251).
"""
from praisonaiui.auth import (
AuthMiddleware,
login_handler,
@@ -2874,10 +2881,13 @@ def create_app(
config = load_config_from_yaml(config_path)
# Extract auth settings from config
_config_requires_token = False
if config:
auth_config = config.get("auth", {})
if auth_config.get("requireAuth") or auth_config.get("require_auth"):
require_auth = True
if auth_config.get("requireToken") or auth_config.get("require_token"):
_config_requires_token = True
middleware = [
Middleware(
@@ -2891,10 +2901,20 @@ def create_app(
# Add optional AUTH_ENFORCE middleware (env var driven)
middleware.append(Middleware(AuthEnforcementMiddleware))
# URL-token auth — opt-in via env. Enables query-param / cookie auth so
# dashboards can be shared by URL (Jupyter model).
# URL-token auth — opt-in. Enables query-param / cookie auth so dashboards
# can be shared by URL (Jupyter model). The mere presence of a token env
# var (e.g. GATEWAY_AUTH_TOKEN leaking into a developer shell after a
# gateway run) must NOT force auth on standalone create_app() — that broke
# ad-hoc TestClient scripts (#238, #251). Activation now requires an
# explicit opt-in: AIUI_REQUIRE_TOKEN=1, YAML auth.requireToken, or the
# require_auth flag.
_tok = os.environ.get("AIUI_URL_TOKEN") or os.environ.get("GATEWAY_AUTH_TOKEN")
if _tok:
_token_auth_opt_in = (
os.environ.get("AIUI_REQUIRE_TOKEN", "").lower() in ("true", "1", "yes")
or _config_requires_token
or require_auth
)
if _tok and _token_auth_opt_in:
from praisonaiui.auth import TokenQueryMiddleware
middleware.append(
Middleware(TokenQueryMiddleware, expected_token=_tok)
@@ -1364,6 +1364,46 @@ async function consumeToken(token) {
}
}
async function probeGatewayReachable(urlString) {
let base;
try {
base = new URL(urlString);
} catch (e) {
return { ok: false, reason: 'invalid_url' };
}
if (base.protocol !== 'http:' && base.protocol !== 'https:') {
return { ok: false, reason: 'invalid_url' };
}
const probe = new URL('/api/auth/status', base);
const sameOrigin = base.origin === window.location.origin;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(probe.toString(), {
method: 'GET',
credentials: 'omit',
signal: controller.signal,
});
clearTimeout(timeout);
if (res.type === 'opaque' || res.status === 0) {
return { ok: false, reason: 'opaque' };
}
return { ok: true, status: res.status };
} catch (e) {
clearTimeout(timeout);
if (e.name === 'AbortError') {
return { ok: false, reason: 'timeout' };
}
// A rejected fetch on a cross-origin URL is ambiguous: the gateway may be
// up but blocking CORS, or genuinely unreachable. The browser cannot tell
// these apart, so surface the "Continue anyway" opt-in rather than a
// definitive unreachable error. Same-origin rejections are genuine.
return { ok: false, reason: sameOrigin ? 'network' : 'opaque' };
}
}
async function maybeShowOnboarding() {
let params;
try {
@@ -1449,18 +1489,36 @@ function renderOnboarding(status) {
<button id="ob-continue" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
<button id="ob-standalone" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Use standalone mode</button>
`);
overlay.querySelector('#ob-continue').addEventListener('click', async () => {
const continueBtn = overlay.querySelector('#ob-continue');
continueBtn.addEventListener('click', async () => {
const url = overlay.querySelector('#ob-url').value.trim();
const errEl = overlay.querySelector('#ob-url-err');
const override = overlay.querySelector('#ob-url-override');
errEl.textContent = '';
try {
await fetch(url, { method: 'HEAD', mode: 'no-cors' });
} catch (e) {
errEl.textContent = `Cannot reach gateway at ${url}`;
if (override && override.checked) {
state.step = 2;
render();
return;
}
state.step = 2;
render();
continueBtn.disabled = true;
continueBtn.textContent = 'Checking…';
const result = await probeGatewayReachable(url);
continueBtn.disabled = false;
continueBtn.textContent = 'Continue';
if (result.ok) {
state.step = 2;
render();
return;
}
if (result.reason === 'invalid_url') {
errEl.textContent = `Enter a valid http(s) URL`;
} else if (result.reason === 'opaque') {
errEl.innerHTML = `Cannot verify gateway from browser. <label style="cursor:pointer"><input id="ob-url-override" type="checkbox" style="vertical-align:middle"> Continue anyway</label>`;
} else if (result.reason === 'timeout') {
errEl.textContent = `Gateway did not respond at ${url}`;
} else {
errEl.textContent = `Cannot reach gateway at ${url}`;
}
});
overlay.querySelector('#ob-standalone').addEventListener('click', () => {
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
@@ -36,6 +36,50 @@ let contextRefreshDebounce = null;
let lastContextPct = 0;
let finopsConfig = null;
let sessionCostState = { tokens: 0, model: '', estimate: false };
let lastUserMessage = ''; // last sent user text, drives per-turn memory search
// ── i18n State ───────────────────────────────────────────────────
let i18nStrings = {};
const I18N_FALLBACK = {
'chat.context.healthy': 'Context healthy',
'chat.context.warning': 'Approaching context limit',
'chat.context.critical': 'Context nearly full',
'chat.context.estimate': 'Estimate',
'chat.compaction.banner': 'Context nearly full — older messages may be summarized on next send',
'chat.compaction.compacted': 'Context compacted',
'chat.memory.chip': '{count} memories',
'chat.memory.empty_turn': 'No memories for this turn',
};
function t(key, params) {
let text = i18nStrings[key] != null ? i18nStrings[key] : (I18N_FALLBACK[key] != null ? I18N_FALLBACK[key] : key);
if (params) {
for (const k in params) {
text = text.split('{' + k + '}').join(String(params[k]));
}
}
return text;
}
async function loadI18nStrings() {
let locale = 'en';
try {
const stored = (typeof localStorage !== 'undefined') ? localStorage.getItem('aiui-locale') : null;
if (stored) {
locale = stored;
} else {
const lr = await fetch('/api/i18n/locale');
if (lr.ok) { const ld = await lr.json(); if (ld && ld.locale) locale = ld.locale; }
}
} catch (e) { /* fall back to English */ }
try {
const resp = await fetch('/api/i18n/strings/' + encodeURIComponent(locale));
if (resp.ok) {
const data = await resp.json();
i18nStrings = (data && data.strings) || {};
}
} catch (e) { /* keep English fallback */ }
}
// ── Floating Chat State ──────────────────────────────────────────
let chatModeConfig = { mode: 'fullpage' };
@@ -699,7 +743,10 @@ function setupResizeHandles(wrapper) {
export async function render(container) {
injectStyles();
containerRef = container;
// Load translations for ring/banner/chip (English fallback on failure)
await loadI18nStrings();
// Fetch chat mode config
try {
const configRes = await fetch('/ui-config.json');
@@ -790,15 +837,16 @@ export async function render(container) {
<textarea id="chat-input" placeholder="Type a message…" rows="1"></textarea>
<div class="chat-compose-actions">
<div class="chat-session-cost-chip" id="chat-session-cost-chip" tabindex="0"></div>
<div class="chat-ring-wrap chat-ring-healthy" id="chat-ring-wrap">
<div class="chat-ring-wrap chat-ring-healthy" id="chat-ring-wrap"
role="progressbar" aria-label="Context usage"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<svg viewBox="0 0 48 48" aria-hidden="true">
<circle class="chat-ring-track" cx="24" cy="24" r="20"></circle>
<circle class="chat-ring-arc" id="chat-ring-arc" cx="24" cy="24" r="20"
stroke-dasharray="125.66" stroke-dashoffset="125.66"></circle>
</svg>
<button class="chat-ring-send" id="chat-send-btn" title="Send"
role="progressbar" aria-label="Send message"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">➤</button>
type="button" aria-label="Send message">➤</button>
<div class="chat-ring-tooltip" id="chat-ring-tooltip"></div>
</div>
</div>
@@ -1864,7 +1912,6 @@ function ringStateFor(pct) {
function updateContextRing(stats) {
const wrap = document.getElementById('chat-ring-wrap');
const arc = document.getElementById('chat-ring-arc');
const sendBtn = document.getElementById('chat-send-btn');
if (!wrap || !arc) return;
const used = Number(stats.used) || 0;
@@ -1888,10 +1935,8 @@ function updateContextRing(stats) {
arc.setAttribute('stroke-dasharray', RING_CIRCUMFERENCE.toFixed(2));
arc.setAttribute('stroke-dashoffset', (RING_CIRCUMFERENCE * (1 - clamped / 100)).toFixed(2));
if (sendBtn) {
sendBtn.setAttribute('aria-valuenow', String(Math.round(pct)));
sendBtn.setAttribute('aria-valuemax', '100');
}
wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
wrap.setAttribute('aria-valuemax', '100');
updateRingTooltip(stats, pct);
updateCompactionBanner(pct, estimate);
@@ -1912,9 +1957,11 @@ function updateRingTooltip(stats, pct) {
['Memory', b.memory || 0],
];
const maxVal = Math.max(1, ...rows.map((r) => r[1]));
const title = stats.estimate ? 'Context breakdown (estimate)' : 'Context breakdown';
const statusKey = pct >= 85 ? 'chat.context.critical' : (pct >= 60 ? 'chat.context.warning' : 'chat.context.healthy');
const title = stats.estimate ? escapeHtml(t('chat.context.estimate')) : escapeHtml(t(statusKey));
tip.innerHTML =
'<h5>' + title + '</h5>' +
'<div class="chat-ring-tt-status">' + escapeHtml(t(statusKey)) + '</div>' +
rows.map(([lbl, val]) =>
'<div class="chat-ring-tt-row">' +
'<span class="lbl">' + lbl + '</span>' +
@@ -1942,9 +1989,7 @@ function updateCompactionBanner(pct, estimate) {
banner.classList.add('open');
banner.classList.toggle('critical', pct >= 95);
banner.setAttribute('role', pct >= 95 ? 'alert' : 'status');
textEl.textContent = pct >= 95
? 'Context ' + Math.round(pct) + '% — older messages will be summarized on next send'
: 'Context ' + Math.round(pct) + '% — older messages may be summarized on next send';
textEl.textContent = t('chat.compaction.banner');
}
function dismissCompactionBanner() {
@@ -1962,7 +2007,7 @@ function flashCompactionCompacted(data) {
banner.classList.remove('critical');
banner.setAttribute('role', 'status');
const removed = data && data.removed_tokens ? ' (' + data.removed_tokens + ' tokens freed)' : '';
textEl.textContent = 'Context compacted' + removed;
textEl.textContent = t('chat.compaction.compacted') + removed;
sessionStorage.removeItem('aiui-compaction-dismissed');
setTimeout(() => {
if (lastContextPct < 85) banner.classList.remove('open');
@@ -2009,32 +2054,46 @@ function updateMemoryChip(count, loading) {
chip.classList.remove('loading');
const n = Number(count) || 0;
if (n <= 0) {
// E5: hide chip when no memories retrieved this turn
chip.classList.remove('visible');
chip.innerHTML = '';
// E5: no memories retrieved this turn — show muted empty-turn label
chip.classList.add('visible');
chip.title = t('chat.memory.empty_turn');
chip.innerHTML = '🧠 0';
return;
}
chip.classList.add('visible');
chip.innerHTML = '🧠 ' + n + ' ' + (n === 1 ? 'memory' : 'memories');
chip.title = '';
chip.innerHTML = '🧠 ' + escapeHtml(t('chat.memory.chip', { count: n }));
}
// Per-turn memory search: count hits for the last user message, not total store.
async function refreshMemoryChip() {
const chip = document.getElementById('chat-memory-chip');
if (!chip) return;
const query = (lastUserMessage || '').trim();
if (!query) {
updateMemoryChip(0, false);
return;
}
updateMemoryChip(0, true);
try {
const endpoint = currentSessionId
? '/api/memory/session/' + encodeURIComponent(currentSessionId)
: '/api/memory';
let resp = await fetch(endpoint);
if (!resp.ok) resp = await fetch('/api/memory');
if (!resp.ok) { chip.classList.remove('loading'); chip.innerHTML = '🧠 —'; chip.classList.add('visible'); return; }
const resp = await fetch('/api/memory/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
limit: 10,
session_id: currentSessionId || undefined,
}),
});
if (!resp.ok) { chip.classList.remove('loading'); chip.classList.add('visible'); chip.title = ''; chip.innerHTML = '🧠 —'; return; }
const data = await resp.json();
const items = data.memories || [];
updateMemoryChip(items.length, false);
const results = data.results || [];
updateMemoryChip(results.length, false);
} catch (e) {
// E6: memory search failed — muted dash
chip.classList.remove('loading');
chip.classList.add('visible');
chip.title = '';
chip.innerHTML = '🧠 —';
}
}
@@ -2335,6 +2394,9 @@ function sendMessage() {
const content = input.value.trim();
if (!content && pendingAttachments.length === 0) return;
// Track last user text so the memory chip searches this turn's query
if (content) lastUserMessage = content;
// Generate session ID if needed
if (!currentSessionId) {
currentSessionId = crypto.randomUUID ? crypto.randomUUID() :
+9 -5
View File
@@ -49,13 +49,16 @@ templates:
routes:
- match: "/docs/**"
template: docs
"""
""",
encoding="utf-8",
)
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "index.md").write_text("# Welcome\n\nHello from integration test.")
(docs_dir / "guide.md").write_text("# Guide\n\nA test guide.")
(docs_dir / "index.md").write_text(
"# Welcome\n\nHello from integration test.", encoding="utf-8"
)
(docs_dir / "guide.md").write_text("# Guide\n\nA test guide.", encoding="utf-8")
return tmp_path
@@ -116,7 +119,7 @@ class TestBuildCommand:
def test_build_invalid_config(self, tmp_path: Path):
config = tmp_path / "bad.yaml"
config.write_text("not: valid: yaml: config")
config.write_text("not: valid: yaml: config", encoding="utf-8")
result = runner.invoke(
app,
["build", "--config", str(config), "--output", str(tmp_path / "out")],
@@ -141,7 +144,8 @@ templates:
routes:
- match: "/docs/**"
template: docs
"""
""",
encoding="utf-8",
)
result = runner.invoke(
app,
+1 -1
View File
@@ -305,7 +305,7 @@ class TestAudioSessionManagement:
session = _audio_sessions[session_id]
assert session["chunks_received"] == 1
assert session["bytes_received"] == len(test_data)
assert session["last_chunk_at"] > session["started_at"]
assert session["last_chunk_at"] >= session["started_at"]
# Check global stats were updated
assert _audio_stats["total_chunks"] == 1
+24 -12
View File
@@ -224,25 +224,37 @@ def test_constant_time_comparison():
def test_timing_attack_resistance():
"""Test timing attack resistance - all incorrect tokens take similar time."""
"""Test timing attack resistance - all incorrect tokens take similar time.
Timing is inherently noisy: a single sample per token trips on scheduler
spikes when the full suite runs under CPU contention (issue #256). We take
several samples per token and compare the *median* time, which is resistant
to transient spikes, so the test measures the constant-time property rather
than system load.
"""
correct = "secret123456789012345678901234567890"
wrong_same_length = "wrongg123456789012345678901234567890"
wrong_diff_length = "wrong"
tokens = [wrong_same_length, wrong_diff_length, ""]
# Measure times for different wrong tokens
times = []
for wrong_token in [wrong_same_length, wrong_diff_length, ""]:
start = time.perf_counter()
for _ in range(1000): # Multiple iterations for better timing measurement
_constant_time_eq(correct, wrong_token)
end = time.perf_counter()
times.append(end - start)
def _median_time(wrong_token: str) -> float:
samples = []
for _ in range(7):
start = time.perf_counter()
for _ in range(1000):
_constant_time_eq(correct, wrong_token)
samples.append(time.perf_counter() - start)
samples.sort()
return samples[len(samples) // 2]
# Times should be similar (within reasonable variance)
# This is a best-effort test - timing can be affected by system load
# Warm up so JIT/cache effects do not skew the first token's samples.
_median_time(correct)
times = [_median_time(token) for token in tokens]
# Median times should be similar (all wrong tokens take comparable time).
avg_time = sum(times) / len(times)
for t in times:
# Allow up to 50% variance due to system noise
assert abs(t - avg_time) / avg_time < 0.5, f"Timing variance too high: {times}"
+126
View File
@@ -0,0 +1,126 @@
"""Unit tests for issue #208 — i18n wiring, per-turn memory search, and a11y.
Covers post-merge gaps left by PR #206 on the chat context ring UI:
* Gap A — ring/banner/chip strings consume i18n via a JS ``t()`` helper.
* Gap B — memory chip uses ``POST /api/memory/search`` (per-turn hits),
not ``GET /api/memory`` (total store).
* Gap C — ``role="progressbar"`` lives on the ring wrapper, not the send button.
"""
from __future__ import annotations
from pathlib import Path
from praisonaiui.features.i18n import JSONLocaleManager
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
CHAT_JS = FRONTEND / "views" / "chat.js"
def _src() -> str:
return CHAT_JS.read_text(encoding="utf-8")
class TestGapAI18nWiring:
def test_i18n_loader_present(self):
src = _src()
assert "async function loadI18nStrings(" in src
assert "/api/i18n/strings/" in src
assert "await loadI18nStrings()" in src
def test_t_helper_with_fallback(self):
src = _src()
assert "function t(key, params)" in src
assert "I18N_FALLBACK" in src
def test_i18n_used_at_least_four_times(self):
# E2E regression bar from the issue: i18n key lookups >= 4
src = _src()
assert src.count("t('chat.") >= 4
def test_banner_uses_i18n_key(self):
src = _src()
assert "t('chat.compaction.banner')" in src
def test_compacted_flash_uses_i18n_key(self):
src = _src()
assert "t('chat.compaction.compacted')" in src
def test_memory_chip_uses_i18n_key(self):
src = _src()
assert "t('chat.memory.chip'" in src
assert "t('chat.memory.empty_turn')" in src
def test_tooltip_uses_context_status_keys(self):
src = _src()
assert "chat.context.critical" in src
assert "chat.context.warning" in src
assert "chat.context.healthy" in src
assert "t('chat.context.estimate')" in src
class TestGapBMemorySearch:
def test_search_endpoint_used(self):
# E2E regression bar: memory/search present >= 1
src = _src()
assert "/api/memory/search" in src
def test_search_is_post_with_query(self):
src = _src()
assert "method: 'POST'" in src
assert "lastUserMessage" in src
assert "query," in src
def test_chip_counts_results_not_total(self):
src = _src()
assert "data.results || []" in src
assert "results.length" in src
def test_last_user_message_tracked_on_send(self):
src = _src()
assert "lastUserMessage = content" in src
class TestGapCAccessibility:
def test_progressbar_role_on_wrapper(self):
src = _src()
# Wrapper div carries the progressbar role + aria values
assert 'id="chat-ring-wrap"' in src
wrap_idx = src.index('id="chat-ring-wrap"')
wrap_slice = src[wrap_idx:wrap_idx + 260]
assert 'role="progressbar"' in wrap_slice
assert "aria-valuenow" in wrap_slice
assert "aria-valuemax" in wrap_slice
def test_send_button_is_button_not_progressbar(self):
src = _src()
btn_idx = src.index('id="chat-send-btn"')
btn_slice = src[btn_idx:btn_idx + 160]
assert 'type="button"' in btn_slice
assert 'role="progressbar"' not in btn_slice
assert 'aria-label="Send message"' in btn_slice
def test_aria_updates_target_wrapper(self):
src = _src()
assert "wrap.setAttribute('aria-valuenow'" in src
class TestI18nMemoryChipLocales:
def test_chip_translation_interpolates_count_all_locales(self):
mgr = JSONLocaleManager()
for locale, word in (("en", "memories"), ("es", "recuerdos"), ("fr", "souvenirs")):
out = mgr.t("chat.memory.chip", locale=locale, count=2)
assert "2" in out
assert word in out
def test_banner_localised(self):
mgr = JSONLocaleManager()
assert mgr.t("chat.compaction.banner", locale="es") != mgr.t(
"chat.compaction.banner", locale="en"
)
assert mgr.t("chat.compaction.banner", locale="fr") != mgr.t(
"chat.compaction.banner", locale="en"
)
+12 -1
View File
@@ -21,12 +21,23 @@ def client():
_agents.clear()
_callbacks.clear()
import praisonaiui.server as _srv
from praisonaiui.features import _features
_srv._DEEP_HEALTH_CACHE = {}
_srv._DEEP_HEALTH_CACHE_TIME = 0.0
# Snapshot and clear the global feature registry so a feature leaked by an
# earlier test (e.g. one that crashed before its cleanup) cannot slow down
# /health and flake the deep-health timing/cache assertions under the full
# suite. See issue #256.
_features_snapshot = dict(_features)
_features.clear()
set_datastore(MemoryDataStore())
app = create_app()
return TestClient(app)
try:
yield TestClient(app)
finally:
_features.clear()
_features.update(_features_snapshot)
class TestHealthEndpoint:
+6 -4
View File
@@ -89,13 +89,15 @@ async def test_missing_dependencies_error(monkeypatch):
@pytest.mark.asyncio
async def test_default_sqlite_url():
async def test_default_sqlite_url(tmp_path):
"""Default should create ~/.praisonaiui/aiui.db"""
from praisonaiui.datastore import SQLAlchemyDataStore
with patch.dict(os.environ, {}, clear=True):
home = str(tmp_path)
env = {"HOME": home, "USERPROFILE": home}
with patch.dict(os.environ, env, clear=True):
store = SQLAlchemyDataStore()
expected = str(Path.home() / ".praisonaiui" / "aiui.db")
expected = (Path(home) / ".praisonaiui" / "aiui.db").as_posix()
assert expected in store._database_url
assert store._database_url.startswith("sqlite+aiosqlite:///")
@@ -108,7 +110,7 @@ async def test_respects_aiui_data_dir_env():
with tempfile.TemporaryDirectory() as tmpdir:
with patch.dict(os.environ, {"AIUI_DATA_DIR": tmpdir}):
store = SQLAlchemyDataStore()
expected = str(Path(tmpdir) / "aiui.db")
expected = (Path(tmpdir) / "aiui.db").as_posix()
assert expected in store._database_url
+67
View File
@@ -0,0 +1,67 @@
"""Regression tests for opt-in token auth activation (#238, #251).
The mere presence of GATEWAY_AUTH_TOKEN / AIUI_URL_TOKEN in the environment
(e.g. leaking into a developer shell after a gateway run) must not force 401
on standalone create_app() + TestClient. Token auth activates only when
explicitly opted in via AIUI_REQUIRE_TOKEN, YAML auth.requireToken, or
require_auth=True.
"""
import pytest
from starlette.testclient import TestClient
from praisonaiui import server
from praisonaiui.server import create_app
@pytest.fixture(autouse=True)
def _reset_state():
server.reset_state()
yield
server.reset_state()
def test_env_token_alone_does_not_force_401(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 200
def test_aiui_url_token_alone_does_not_force_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 200
def test_require_token_env_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.setenv("AIUI_REQUIRE_TOKEN", "1")
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 401
def test_require_token_env_opt_in_allows_bearer(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.setenv("AIUI_REQUIRE_TOKEN", "1")
client = TestClient(create_app())
resp = client.get(
"/api/agents",
headers={"Authorization": "Bearer test-secret-token"},
)
assert resp.status_code == 200
def test_config_require_token_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app(config={"auth": {"requireToken": True}}))
assert client.get("/api/agents").status_code == 401
def test_require_auth_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app(require_auth=True))
assert client.get("/api/agents").status_code == 401
+26 -11
View File
@@ -151,17 +151,32 @@ class TestCompetitorScrub:
"""The peer framework's name must never reappear in the source tree."""
def test_zero_matches_in_source(self):
import subprocess
r = subprocess.run(
["grep", "-rn", "-i", "chainlit",
"src/praisonaiui", "docs", "examples", "tests",
"--include=*.py", "--include=*.md", "--include=*.yaml",
"--include=*.yml", "--include=*.ts", "--include=*.tsx"],
capture_output=True, text=True, cwd=_repo_root(),
)
hits = [line for line in r.stdout.splitlines()
if ".pyc" not in line
and "tests/unit/test_unified_naming" not in line]
import pathlib
root = pathlib.Path(_repo_root())
needle = "chainlit"
suffixes = {".py", ".md", ".yaml", ".yml", ".ts", ".tsx"}
search_dirs = ["src/praisonaiui", "docs", "examples", "tests"]
hits = []
for rel in search_dirs:
base = root / rel
if not base.exists():
continue
for path in base.rglob("*"):
if not path.is_file() or path.suffix not in suffixes:
continue
rel_path = path.relative_to(root).as_posix()
if "test_unified_naming" in rel_path:
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
if needle in line.lower():
hits.append(f"{rel_path}:{lineno}:{line}")
assert not hits, f"Competitor name reappeared: {hits}"
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.135"
version = "0.3.138"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },