mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-14 19:02:22 +02:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec4d1805d | ||
|
|
54d794e8de | ||
|
|
3cd6cdb638 | ||
|
|
2c394704c6 | ||
|
|
f9235ebbf1 | ||
|
|
49e4e55d2c | ||
|
|
b2789d04fb | ||
|
|
a6bc86e331 | ||
|
|
c4369305f0 | ||
|
|
b52296471b | ||
|
|
53869d194d | ||
|
|
17ee856d1c | ||
|
|
e7eddbae13 | ||
|
|
93eb10d4f0 | ||
|
|
3f9633c44f | ||
|
|
858c872832 | ||
|
|
1939a6ad2d | ||
|
|
937c883c41 | ||
|
|
e0615cda47 | ||
|
|
1976fe1b60 | ||
|
|
adfe3ab379 | ||
|
|
d87a913729 | ||
|
|
bea48c749c | ||
|
|
5a016e492c | ||
|
|
93653120d6 | ||
|
|
c2b9666def | ||
|
|
1183fe0ff1 | ||
|
|
663d6879b7 | ||
|
|
3bea7a53ee |
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
|
||||
# Least privilege: none of the jobs write to the repo.
|
||||
@@ -103,10 +103,7 @@ jobs:
|
||||
python-tests:
|
||||
name: Python tests (pytest)
|
||||
runs-on: ubuntu-latest
|
||||
# Informational for now: the suite has known flaky / environment-dependent
|
||||
# failures (test isolation + embedding-model assertions). Tracked under the
|
||||
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
||||
continue-on-error: true
|
||||
# Make Python test validation authoritative for the configured scope.
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -630,13 +630,24 @@ app.include_router(auth_router)
|
||||
|
||||
@app.post("/api/activity/heartbeat")
|
||||
async def activity_heartbeat():
|
||||
from src.interactive_gate import mark_browser_activity
|
||||
from src.interactive_gate import (
|
||||
mark_browser_activity,
|
||||
maybe_stop_background_tasks_for_heartbeat,
|
||||
)
|
||||
|
||||
await mark_browser_activity()
|
||||
|
||||
async def _stop_background():
|
||||
try:
|
||||
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
|
||||
await maybe_stop_background_tasks_for_heartbeat(
|
||||
task_scheduler.stop_background_tasks_for_foreground
|
||||
)
|
||||
except Exception:
|
||||
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True)
|
||||
logging.getLogger("app.foreground_gate").debug(
|
||||
"heartbeat task stop failed",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(_stop_background())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
+237
-62
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
|
||||
from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, inspect, text
|
||||
from sqlalchemy.engine import Engine, make_url
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||
@@ -430,6 +430,93 @@ class EmailAccount(TimestampMixin, Base):
|
||||
)
|
||||
|
||||
|
||||
class EmailAccountOwnerLock(Base):
|
||||
"""Durable per-owner mutex for email-account default mutations.
|
||||
|
||||
Row-locking databases serialize mutations by locking this row before they
|
||||
inspect or stage EmailAccount changes. SQLite uses ``BEGIN IMMEDIATE``
|
||||
instead, because it ignores ``SELECT ... FOR UPDATE``; keeping the table in
|
||||
the shared metadata still makes the non-SQLite path available without a
|
||||
separate migration. The empty key represents the normalized legacy /
|
||||
unconfigured scope shared by ``owner IS NULL`` and ``owner = ''`` rows.
|
||||
"""
|
||||
__tablename__ = "email_account_owner_locks"
|
||||
|
||||
owner_key = Column(String, primary_key=True)
|
||||
|
||||
|
||||
_EMAIL_ACCOUNT_DEFAULT_INDEX = "ux_email_accounts_one_default_per_owner"
|
||||
_EMAIL_ACCOUNT_DEFAULT_INDEX_DDL = {
|
||||
"sqlite": (
|
||||
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
|
||||
"ON email_accounts (COALESCE(owner, '')) WHERE is_default = 1"
|
||||
),
|
||||
"postgresql": (
|
||||
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
|
||||
"ON email_accounts ((COALESCE(owner, ''))) WHERE is_default IS TRUE"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# SQLAlchemy cannot express one portable partial, functional index across the
|
||||
# two supported database families. Register dialect-specific DDL so fresh
|
||||
# databases get the invariant as part of create_all(); the startup migration
|
||||
# below installs the same index on existing databases after normalizing legacy
|
||||
# duplicate rows.
|
||||
for _dialect_name, _index_ddl in _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.items():
|
||||
event.listen(
|
||||
EmailAccount.__table__,
|
||||
"after_create",
|
||||
DDL(_index_ddl).execute_if(dialect=_dialect_name),
|
||||
)
|
||||
|
||||
|
||||
def lock_email_account_owner_mutations(db, *owners: str) -> None:
|
||||
"""Lock normalized email-account owner scopes in canonical order.
|
||||
|
||||
``NULL`` and the empty string are one legacy/single-user owner partition,
|
||||
matching the unique default-account index. SQLite has only a database
|
||||
writer reservation, while row-locking databases use durable mutex rows.
|
||||
Sorting all requested owner keys keeps multi-owner operations such as user
|
||||
rename from deadlocking with another mutation that requests the same keys
|
||||
in the opposite order.
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
owner_keys = sorted({owner or "" for owner in owners} or {""})
|
||||
if db.get_bind().dialect.name == "sqlite":
|
||||
db.execute(text("BEGIN IMMEDIATE"))
|
||||
return
|
||||
|
||||
for owner_key in owner_keys:
|
||||
lock_row = db.get(
|
||||
EmailAccountOwnerLock,
|
||||
owner_key,
|
||||
with_for_update=True,
|
||||
)
|
||||
if lock_row is not None:
|
||||
continue
|
||||
|
||||
inserted = False
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.add(EmailAccountOwnerLock(owner_key=owner_key))
|
||||
db.flush()
|
||||
inserted = True
|
||||
except IntegrityError:
|
||||
# A competing transaction created the mutex row first. Once its
|
||||
# insert commits, lock that durable row before touching accounts.
|
||||
pass
|
||||
|
||||
if not inserted:
|
||||
(
|
||||
db.query(EmailAccountOwnerLock)
|
||||
.filter(EmailAccountOwnerLock.owner_key == owner_key)
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
class ModelEndpoint(TimestampMixin, Base):
|
||||
"""Admin-configured model endpoints. Models are auto-discovered via /v1/models."""
|
||||
__tablename__ = "model_endpoints"
|
||||
@@ -1404,8 +1491,25 @@ def _migrate_assign_legacy_owner():
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
prefs = _json.load(f)
|
||||
if "_users" not in prefs and prefs:
|
||||
# Flat format → nest under admin user
|
||||
new_prefs = {"_users": {admin_user: prefs}}
|
||||
# Flat format → nest ordinary preferences under the admin
|
||||
# user. Foreground fallback is an explicit per-owner opt-in,
|
||||
# so auth-disabled consent must remain inert at the flat root
|
||||
# rather than becoming consent for the first named owner.
|
||||
foreground_keys = {
|
||||
"foreground_fallback_enabled",
|
||||
"foreground_model_fallbacks",
|
||||
}
|
||||
named_prefs = {
|
||||
key: value
|
||||
for key, value in prefs.items()
|
||||
if key not in foreground_keys
|
||||
}
|
||||
new_prefs = {
|
||||
key: prefs[key]
|
||||
for key in foreground_keys
|
||||
if key in prefs
|
||||
}
|
||||
new_prefs["_users"] = {admin_user: named_prefs}
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
_json.dump(new_prefs, f, indent=2)
|
||||
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
|
||||
@@ -1812,72 +1916,142 @@ class Integration(TimestampMixin, Base):
|
||||
|
||||
|
||||
|
||||
def _migrate_seed_email_account():
|
||||
"""If email_accounts is empty and settings.json has legacy flat imap_host/smtp_host
|
||||
keys, create a single default account from them so nothing breaks for users who
|
||||
upgraded. Safe to run repeatedly — it short-circuits once any row exists."""
|
||||
def _migrate_email_account_default_invariant():
|
||||
"""Normalize legacy duplicates and install durable at-most-one enforcement.
|
||||
|
||||
Older databases only had a non-unique ``(owner, is_default)`` lookup index.
|
||||
Keep the oldest default deterministically in each normalized owner scope,
|
||||
then add the same partial functional unique index used for fresh schemas.
|
||||
"""
|
||||
dialect_name = engine.dialect.name
|
||||
index_ddl = _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.get(dialect_name)
|
||||
if index_ddl is None:
|
||||
logger.warning(
|
||||
"Email-account default uniqueness is not available for database "
|
||||
"dialect %s; mutations remain serialized but are not protected by "
|
||||
"a database constraint",
|
||||
dialect_name,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
tables = [r[0] for r in conn.execute(text(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='email_accounts'"
|
||||
))]
|
||||
if "email_accounts" not in tables:
|
||||
return
|
||||
existing = conn.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0
|
||||
if existing > 0:
|
||||
with engine.begin() as conn:
|
||||
if not inspect(conn).has_table(EmailAccount.__tablename__):
|
||||
return
|
||||
default_rows = conn.execute(text("""
|
||||
SELECT id, owner
|
||||
FROM email_accounts
|
||||
WHERE is_default IS TRUE
|
||||
ORDER BY
|
||||
COALESCE(owner, ''),
|
||||
CASE WHEN created_at IS NULL THEN 1 ELSE 0 END,
|
||||
created_at,
|
||||
id
|
||||
""")).mappings()
|
||||
seen_owner_keys = set()
|
||||
duplicate_ids = []
|
||||
for row in default_rows:
|
||||
owner_key = row["owner"] or ""
|
||||
if owner_key in seen_owner_keys:
|
||||
duplicate_ids.append(row["id"])
|
||||
else:
|
||||
seen_owner_keys.add(owner_key)
|
||||
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
from pathlib import Path
|
||||
settings_file = Path(SETTINGS_FILE)
|
||||
if not settings_file.exists():
|
||||
return
|
||||
try:
|
||||
s = _json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
for account_id in duplicate_ids:
|
||||
conn.execute(
|
||||
text("UPDATE email_accounts SET is_default = :value WHERE id = :id"),
|
||||
{"value": False, "id": account_id},
|
||||
)
|
||||
conn.execute(text(index_ddl))
|
||||
|
||||
imap_host = (s.get("imap_host") or "").strip()
|
||||
smtp_host = (s.get("smtp_host") or "").strip()
|
||||
if not imap_host and not smtp_host:
|
||||
return # nothing to migrate
|
||||
if duplicate_ids:
|
||||
logger.warning(
|
||||
"Normalized %d duplicate default email account(s) before "
|
||||
"installing %s",
|
||||
len(duplicate_ids),
|
||||
_EMAIL_ACCOUNT_DEFAULT_INDEX,
|
||||
)
|
||||
except Exception:
|
||||
# Starting without the constraint would silently retain the race this
|
||||
# migration is intended to close. Fail startup so an operator sees and
|
||||
# can repair an incompatible schema instead of accepting unsafe writes.
|
||||
logger.exception("Failed to enforce the email-account default invariant")
|
||||
raise
|
||||
|
||||
|
||||
def _migrate_seed_email_account():
|
||||
"""Atomically seed one legacy default account when no account exists.
|
||||
|
||||
Reading settings is intentionally done before taking the owner mutex. The
|
||||
decisive emptiness check and insert share one locked transaction, so two
|
||||
application workers starting together cannot both seed a default row.
|
||||
"""
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
|
||||
settings_file = Path(SETTINGS_FILE)
|
||||
if not settings_file.exists():
|
||||
return
|
||||
try:
|
||||
s = _json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
imap_host = (s.get("imap_host") or "").strip()
|
||||
smtp_host = (s.get("smtp_host") or "").strip()
|
||||
if not imap_host and not smtp_host:
|
||||
return
|
||||
|
||||
db = None
|
||||
try:
|
||||
if not inspect(engine).has_table(EmailAccount.__tablename__):
|
||||
return
|
||||
db = SessionLocal()
|
||||
lock_email_account_owner_mutations(db, "")
|
||||
existing = db.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0
|
||||
if existing > 0:
|
||||
return
|
||||
|
||||
now = utcnow_naive()
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
INSERT INTO email_accounts
|
||||
(id, owner, name, is_default, enabled,
|
||||
imap_host, imap_port, imap_user, imap_password, imap_starttls,
|
||||
smtp_host, smtp_port, smtp_user, smtp_password,
|
||||
from_address, created_at, updated_at)
|
||||
VALUES
|
||||
(:id, :owner, :name, :is_default, :enabled,
|
||||
:imap_host, :imap_port, :imap_user, :imap_password, :imap_starttls,
|
||||
:smtp_host, :smtp_port, :smtp_user, :smtp_password,
|
||||
:from_address, :created_at, :updated_at)
|
||||
"""), {
|
||||
"id": _uuid.uuid4().hex,
|
||||
"owner": None,
|
||||
"name": "Default",
|
||||
"is_default": True,
|
||||
"enabled": True,
|
||||
"imap_host": imap_host,
|
||||
"imap_port": int(s.get("imap_port") or 993),
|
||||
"imap_user": s.get("imap_user") or "",
|
||||
"imap_password": s.get("imap_password") or "",
|
||||
"imap_starttls": bool(s.get("imap_starttls", True)),
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": int(s.get("smtp_port") or 465),
|
||||
"smtp_user": s.get("smtp_user") or "",
|
||||
"smtp_password": s.get("smtp_password") or "",
|
||||
"from_address": s.get("email_from") or "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
logging.getLogger(__name__).info("Seeded email_accounts 'Default' from settings.json")
|
||||
db.execute(text("""
|
||||
INSERT INTO email_accounts
|
||||
(id, owner, name, is_default, enabled,
|
||||
imap_host, imap_port, imap_user, imap_password, imap_starttls,
|
||||
smtp_host, smtp_port, smtp_user, smtp_password,
|
||||
from_address, created_at, updated_at)
|
||||
VALUES
|
||||
(:id, :owner, :name, :is_default, :enabled,
|
||||
:imap_host, :imap_port, :imap_user, :imap_password, :imap_starttls,
|
||||
:smtp_host, :smtp_port, :smtp_user, :smtp_password,
|
||||
:from_address, :created_at, :updated_at)
|
||||
"""), {
|
||||
"id": _uuid.uuid4().hex,
|
||||
"owner": None,
|
||||
"name": "Default",
|
||||
"is_default": True,
|
||||
"enabled": True,
|
||||
"imap_host": imap_host,
|
||||
"imap_port": int(s.get("imap_port") or 993),
|
||||
"imap_user": s.get("imap_user") or "",
|
||||
"imap_password": s.get("imap_password") or "",
|
||||
"imap_starttls": bool(s.get("imap_starttls", True)),
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": int(s.get("smtp_port") or 465),
|
||||
"smtp_user": s.get("smtp_user") or "",
|
||||
"smtp_password": s.get("smtp_password") or "",
|
||||
"from_address": s.get("email_from") or "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
db.commit()
|
||||
logger.info("Seeded email_accounts 'Default' from settings.json")
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"seed email account migration: {e}")
|
||||
if db is not None:
|
||||
db.rollback()
|
||||
logger.warning("seed email account migration: %s", e)
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
# WARNING: Foreign-key enforcement is enabled globally for all SQLite connections.
|
||||
@@ -1960,6 +2134,7 @@ def init_db():
|
||||
_migrate_add_crew_member_id()
|
||||
_migrate_add_assistant_columns()
|
||||
_migrate_add_email_smtp_security()
|
||||
_migrate_email_account_default_invariant()
|
||||
_migrate_seed_email_account()
|
||||
_migrate_add_calendar_metadata()
|
||||
_migrate_add_calendar_is_utc()
|
||||
|
||||
@@ -129,12 +129,17 @@ services:
|
||||
fi
|
||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||
fi
|
||||
# Advisory: a settings file the migration cannot parse or rewrite must
|
||||
# not be what stops searxng from booting. It explains itself on stderr
|
||||
# and we carry on, letting searxng report anything genuinely wrong.
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||
|
||||
@@ -132,12 +132,17 @@ services:
|
||||
fi
|
||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||
fi
|
||||
# Advisory: a settings file the migration cannot parse or rewrite must
|
||||
# not be what stops searxng from booting. It explains itself on stderr
|
||||
# and we carry on, letting searxng report anything genuinely wrong.
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||
|
||||
@@ -110,12 +110,17 @@ services:
|
||||
fi
|
||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||
fi
|
||||
# Advisory: a settings file the migration cannot parse or rewrite must
|
||||
# not be what stops searxng from booting. It explains itself on stderr
|
||||
# and we carry on, letting searxng report anything genuinely wrong.
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||
|
||||
+174
@@ -309,6 +309,32 @@ container. Cookbook **Serve** is a separate workflow for serving downloaded
|
||||
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
|
||||
install usually only need to add the endpoint in Settings.
|
||||
|
||||
**Tool calls not firing on a manually-added Ollama `/v1` endpoint.** By
|
||||
design, a local Ollama `/v1` endpoint defaults to the conservative
|
||||
text-based (fenced-block) tool-calling path rather than native structured
|
||||
tool calls, since some locally-served models mishandle native schemas (see
|
||||
#1567). This is correct for most local setups, but if you know your specific
|
||||
model reliably supports native tool calling (check `ollama show <model>` for
|
||||
`tools` under Capabilities), you can opt that endpoint in explicitly. There
|
||||
is currently no UI control for this on manually-added endpoints (see #5192);
|
||||
the flag can still be set directly against the existing API, from a browser
|
||||
console on an authenticated admin session:
|
||||
|
||||
```js
|
||||
fetch('/api/model-endpoints/<endpoint-id>', {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({supports_tools: true})
|
||||
}).then(r => r.json()).then(console.log)
|
||||
```
|
||||
|
||||
Find `<endpoint-id>` by inspecting the `/api/model-endpoints` response (or
|
||||
your browser's network tab while Settings loads the endpoint list). Send
|
||||
`supports_tools: false` to disable native structured tool calls and force the
|
||||
conservative fenced/text path, or `supports_tools: null` to return the endpoint
|
||||
to the Auto heuristic.
|
||||
|
||||
**Useful checks.**
|
||||
|
||||
```bash
|
||||
@@ -471,6 +497,154 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
|
||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||
|
||||
#### Faster over the network: HTTP/2
|
||||
|
||||
The frontend is raw ES modules with no bundler, so a page load is a few hundred
|
||||
small same-origin requests. Over HTTP/1.1 browsers typically allow only a small
|
||||
number of concurrent connections per host (commonly around six), so many of
|
||||
those requests are serialized across multiple round trips. On localhost that
|
||||
costs almost nothing. Over a LAN, VPN, or remote link it can become a major
|
||||
part of load time, especially as latency increases.
|
||||
|
||||
HTTP/2 multiplexes them onto one connection and the serialisation disappears.
|
||||
Odysseus needs no changes for this — uvicorn keeps speaking HTTP/1.1 on
|
||||
loopback and the proxy speaks HTTP/2 to the browser. Mainstream browsers
|
||||
negotiate HTTP/2 for normal web pages over TLS; they do not use the cleartext
|
||||
h2c mode here, so browser-facing HTTP/2 requires a certificate. The
|
||||
`--ssl-certfile` route in *HTTPS + LAN/Tailscale exposure* above gives you
|
||||
HTTPS but not HTTP/2 — uvicorn does not speak it.
|
||||
|
||||
**1. Install Caddy.** See the [install docs](https://caddyserver.com/docs/install)
|
||||
for your platform; on macOS, `brew install caddy`.
|
||||
|
||||
**2. Write a `Caddyfile`.** Pick the block that matches how you reach the
|
||||
machine. Replace `7000` if Odysseus listens elsewhere — the macOS start script
|
||||
uses `7860`.
|
||||
|
||||
Public domain, Caddy obtains and renews the certificate itself:
|
||||
|
||||
```
|
||||
odysseus.example.com {
|
||||
reverse_proxy 127.0.0.1:7000
|
||||
}
|
||||
```
|
||||
|
||||
Tailscale, no public DNS needed — `tailscale cert` issues a browser-trusted
|
||||
certificate for a tailnet name and writes `<domain>.crt` and `<domain>.key`:
|
||||
|
||||
```bash
|
||||
tailscale cert myhost.tailnet-name.ts.net
|
||||
```
|
||||
|
||||
```
|
||||
myhost.tailnet-name.ts.net {
|
||||
tls /path/to/myhost.tailnet-name.ts.net.crt /path/to/myhost.tailnet-name.ts.net.key
|
||||
reverse_proxy 127.0.0.1:7000
|
||||
}
|
||||
```
|
||||
|
||||
LAN with your own certificate — same shape, your own files:
|
||||
|
||||
```
|
||||
odysseus.lan {
|
||||
tls /path/to/cert.pem /path/to/key.pem
|
||||
reverse_proxy 127.0.0.1:7000
|
||||
}
|
||||
```
|
||||
|
||||
Give `tls` absolute paths: a service starts in a working directory you did not
|
||||
choose. If port 443 is already taken, append a port to the site address
|
||||
(`odysseus.example.com:8443`) and use it in the URL. That alone does not free
|
||||
port 80 — Caddy still binds it for the HTTP-to-HTTPS redirect, and fails to
|
||||
start with `listen tcp :80: bind: address already in use` if something else
|
||||
holds it. Turn the redirect off with a global block at the top of the file:
|
||||
|
||||
```
|
||||
{
|
||||
auto_https disable_redirects
|
||||
}
|
||||
```
|
||||
|
||||
**3. Run it in the foreground first:**
|
||||
|
||||
```bash
|
||||
caddy run --config ./Caddyfile
|
||||
```
|
||||
|
||||
Once that works, run it as a service:
|
||||
|
||||
```bash
|
||||
brew services start caddy # macOS — reads $(brew --prefix)/etc/Caddyfile, not ./Caddyfile
|
||||
sudo systemctl enable --now caddy # Linux, if your package installed the unit
|
||||
```
|
||||
|
||||
Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
|
||||
run the proxy as another container, or on the host pointing at the published
|
||||
port.
|
||||
|
||||
**4. Point Odysseus at the new origin** in `.env`, then restart it:
|
||||
|
||||
```bash
|
||||
SECURE_COOKIES=true
|
||||
# only if you use remote MCP servers with OAuth:
|
||||
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
|
||||
```
|
||||
|
||||
Gmail OAuth needs nothing here when the proxy runs on the same host: the
|
||||
redirect URI is built from the incoming request, and uvicorn rewrites the
|
||||
scheme from `X-Forwarded-Proto` for proxies it trusts — by default only
|
||||
`127.0.0.1`. A proxy in a separate container or on another machine is not
|
||||
trusted, so pin the URI there:
|
||||
|
||||
```bash
|
||||
GOOGLE_OAUTH_REDIRECT_URI=https://odysseus.example.com/api/email/oauth/google/callback
|
||||
```
|
||||
|
||||
(uvicorn's own `FORWARDED_ALLOW_IPS` widens that trust, but it has to be in the
|
||||
environment uvicorn starts with — `.env` is read by the app afterwards, too
|
||||
late for it to take effect.)
|
||||
|
||||
**5. Confirm HTTP/2 is really on:**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_version}\n' https://odysseus.example.com/
|
||||
# 2
|
||||
```
|
||||
|
||||
The status code is not the thing to check here — a logged-out request redirects
|
||||
to the login page, so `curl -I` shows `HTTP/2 302`, and the `HTTP/2` prefix is
|
||||
the part that matters. The browser reports the same in the Network panel's
|
||||
Protocol column (`h2`); in Chrome and Firefox that column is hidden until you
|
||||
enable it by right-clicking the column headers.
|
||||
|
||||
Three things bite when moving an existing install behind TLS:
|
||||
|
||||
- Set `SECURE_COOKIES=true` **at the same time** you stop serving plain HTTP,
|
||||
not before. The flag is applied to every login regardless of the scheme the
|
||||
request arrived on, so while an HTTP entrypoint is still reachable the
|
||||
browser will reject the `Secure` cookie there and login will appear to loop.
|
||||
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
|
||||
Gmail redirect URI it cannot be derived from a request — it is registered
|
||||
with each MCP authorization server up front — so set it to the external
|
||||
origin if you use remote MCP servers over OAuth.
|
||||
- Odysseus sends `Strict-Transport-Security` once it sees `X-Forwarded-Proto:
|
||||
https`. HSTS applies to the whole hostname and ignores the port, so any other
|
||||
plain-HTTP service on that same hostname becomes unreachable in browsers that
|
||||
have visited Odysseus. Give Odysseus its own hostname, or strip the header at
|
||||
the proxy (`header_down -Strict-Transport-Security` in Caddy).
|
||||
|
||||
Server-sent events are not buffered by this configuration, so chat streaming
|
||||
arrives token by token; add `flush_interval -1` inside the `reverse_proxy`
|
||||
block if you want that pinned explicitly. nginx needs `proxy_buffering off;`
|
||||
for the same reason.
|
||||
|
||||
Changing the external origin also affects state scoped to it. Service workers
|
||||
and their caches are origin-scoped, so moving to a different origin starts with
|
||||
a cold load. Cookies follow their own domain/path/security rules rather than
|
||||
being port-scoped: changing the hostname normally requires a new login, while
|
||||
changing only the scheme or port does not by itself guarantee that existing
|
||||
cookies disappear.
|
||||
|
||||
Common internal-only ports from the default docs/compose setup:
|
||||
|
||||
| Port | Service |
|
||||
|
||||
@@ -1802,7 +1802,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint,
|
||||
resolve_utility_fallback_candidates,
|
||||
resolve_chat_fallback_candidates,
|
||||
)
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
except Exception as exc:
|
||||
@@ -1843,13 +1842,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
|
||||
utility_fallbacks = resolve_utility_fallback_candidates() or []
|
||||
for cand in utility_fallbacks:
|
||||
_add(*cand)
|
||||
try:
|
||||
chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or []
|
||||
except TypeError:
|
||||
chat_fallbacks = resolve_chat_fallback_candidates() or []
|
||||
for cand in chat_fallbacks:
|
||||
_add(*cand)
|
||||
|
||||
if not candidates:
|
||||
return {"error": "No LLM endpoint configured for AI reply"}
|
||||
|
||||
|
||||
+59
-3
@@ -22,6 +22,8 @@ from src.settings import (
|
||||
load_features as _load_features,
|
||||
save_features as _save_features,
|
||||
DEFAULT_SETTINGS,
|
||||
RETIRED_SETTING_KEYS,
|
||||
without_retired_settings,
|
||||
)
|
||||
from src.integrations import (
|
||||
load_integrations,
|
||||
@@ -345,9 +347,61 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
# docs, email accounts, tasks, etc.
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
from core.database import Base, SessionLocal
|
||||
from core.database import (
|
||||
Base,
|
||||
EmailAccount,
|
||||
SessionLocal,
|
||||
lock_email_account_owner_mutations,
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Email-account defaults are protected by per-owner mutex rows.
|
||||
# A rename crosses two owner partitions, so lock both in the
|
||||
# shared helper's canonical order before inspecting either.
|
||||
lock_email_account_owner_mutations(
|
||||
db, old_username, new_username
|
||||
)
|
||||
|
||||
source_default_ids = [
|
||||
row[0]
|
||||
for row in (
|
||||
db.query(EmailAccount.id)
|
||||
.filter(
|
||||
func.lower(EmailAccount.owner) == old_username,
|
||||
EmailAccount.is_default == True, # noqa: E712
|
||||
)
|
||||
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
||||
.all()
|
||||
)
|
||||
]
|
||||
destination_default_ids = [
|
||||
row[0]
|
||||
for row in (
|
||||
db.query(EmailAccount.id)
|
||||
.filter(
|
||||
func.lower(EmailAccount.owner) == new_username,
|
||||
EmailAccount.is_default == True, # noqa: E712
|
||||
)
|
||||
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
||||
.all()
|
||||
)
|
||||
]
|
||||
if destination_default_ids:
|
||||
clear_default_ids = (
|
||||
destination_default_ids[1:] + source_default_ids
|
||||
)
|
||||
else:
|
||||
clear_default_ids = source_default_ids[1:]
|
||||
if clear_default_ids:
|
||||
(
|
||||
db.query(EmailAccount)
|
||||
.filter(EmailAccount.id.in_(clear_default_ids))
|
||||
.update(
|
||||
{EmailAccount.is_default: False},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
|
||||
for mapper in Base.registry.mappers:
|
||||
model = mapper.class_
|
||||
if not hasattr(model, "owner"):
|
||||
@@ -637,7 +691,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
a scrubbed copy with secret keys blanked. The frontend uses this
|
||||
for keybinds + TTS prefs, so it stays callable without admin."""
|
||||
user = _get_current_user(request)
|
||||
settings = _load_settings()
|
||||
settings = without_retired_settings(_load_settings())
|
||||
if user and auth_manager.is_admin(user):
|
||||
return settings
|
||||
return scrub_settings(settings)
|
||||
@@ -657,6 +711,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
||||
}
|
||||
for key in DEFAULT_SETTINGS:
|
||||
if key in RETIRED_SETTING_KEYS:
|
||||
continue
|
||||
if key not in body:
|
||||
continue
|
||||
val = body[key]
|
||||
@@ -669,7 +725,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
val = max(lo, min(val, hi))
|
||||
current[key] = val
|
||||
_save_settings(current)
|
||||
return current
|
||||
return without_retired_settings(current)
|
||||
|
||||
# ---- Integrations CRUD ----
|
||||
|
||||
|
||||
+115
-7
@@ -10,6 +10,7 @@ from typing import Optional, List
|
||||
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, and_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
||||
@@ -221,22 +222,125 @@ class EventUpdate(BaseModel):
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
_DEFAULT_CALENDAR_NAMESPACE = uuid.UUID("4840613a-9847-4a3b-bd75-19e6bc5fc3ce")
|
||||
|
||||
|
||||
def _default_calendar_id(owner: str, collision_index: int = 0) -> str:
|
||||
"""Return one stable primary-key candidate for an owner's lazy default.
|
||||
|
||||
Slot zero preserves the original owner-derived identifier. Later slots
|
||||
let a username be reused after its prior calendar was migrated to another
|
||||
owner during a rename, without making concurrent first use choose random
|
||||
and therefore divergent identifiers.
|
||||
"""
|
||||
if collision_index == 0:
|
||||
candidate_name = owner
|
||||
else:
|
||||
candidate_name = json.dumps(
|
||||
[owner, collision_index],
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return str(uuid.uuid5(_DEFAULT_CALENDAR_NAMESPACE, candidate_name))
|
||||
|
||||
|
||||
def _begin_sqlite_default_write(db) -> None:
|
||||
"""Serialize an absent-default check with other SQLite writers.
|
||||
|
||||
SQLite's default deferred transactions allow two workers to both read an
|
||||
empty calendar set before either writes. ``BEGIN IMMEDIATE`` acquires the
|
||||
writer reservation before the second, authoritative lookup. We issue it
|
||||
only when the driver has not already opened a write transaction; a caller
|
||||
with a pending write already owns the required reservation.
|
||||
"""
|
||||
connection = db.connection()
|
||||
dbapi_connection = connection.connection
|
||||
driver_connection = getattr(
|
||||
dbapi_connection,
|
||||
"driver_connection",
|
||||
dbapi_connection,
|
||||
)
|
||||
if not getattr(driver_connection, "in_transaction", False):
|
||||
connection.exec_driver_sql("BEGIN IMMEDIATE")
|
||||
|
||||
|
||||
def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
|
||||
"""Create default calendar if none exist for this owner."""
|
||||
"""Return the owner's calendar, staging a default in the caller's transaction.
|
||||
|
||||
A stable owner-derived primary key makes concurrent first-use inserts
|
||||
converge on one row on every SQL backend. SQLite additionally serializes
|
||||
the absent-row check because its deferred transactions otherwise permit
|
||||
both workers to read the gap before either writes. Other backends recover
|
||||
a lost insert race inside a savepoint so the caller's event transaction
|
||||
remains usable and atomic.
|
||||
"""
|
||||
owner = owner or FALLBACK_OWNER
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
||||
if not cal:
|
||||
if cal:
|
||||
return cal
|
||||
|
||||
dialect = db.get_bind().dialect.name
|
||||
if dialect == "sqlite":
|
||||
_begin_sqlite_default_write(db)
|
||||
# Another worker may have committed while BEGIN IMMEDIATE waited.
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
||||
if cal:
|
||||
return cal
|
||||
|
||||
collision_index = 0
|
||||
while True:
|
||||
default_id = _default_calendar_id(owner, collision_index)
|
||||
|
||||
if dialect == "sqlite":
|
||||
# BEGIN IMMEDIATE above makes this occupancy check authoritative:
|
||||
# another SQLite writer cannot rename, delete, or claim this slot
|
||||
# until the caller commits or rolls back.
|
||||
occupant = db.query(CalendarCal).filter(
|
||||
CalendarCal.id == default_id,
|
||||
).first()
|
||||
if occupant is not None:
|
||||
if occupant.owner == owner:
|
||||
return occupant
|
||||
collision_index += 1
|
||||
continue
|
||||
|
||||
cal = CalendarCal(
|
||||
id=str(uuid.uuid4()),
|
||||
id=default_id,
|
||||
owner=owner,
|
||||
name="Personal",
|
||||
color="#5b8abf",
|
||||
source="local",
|
||||
)
|
||||
db.add(cal)
|
||||
db.commit()
|
||||
db.refresh(cal)
|
||||
return cal
|
||||
|
||||
if dialect == "sqlite":
|
||||
db.add(cal)
|
||||
db.flush()
|
||||
return cal
|
||||
|
||||
try:
|
||||
# A uniqueness failure rolls back only this savepoint, not an event
|
||||
# or reminder already staged by the caller's outer transaction.
|
||||
with db.begin_nested():
|
||||
db.add(cal)
|
||||
db.flush()
|
||||
return cal
|
||||
except IntegrityError:
|
||||
# Use a locking/current read so repeatable-read backends can observe
|
||||
# the row that won after our transaction's original empty snapshot.
|
||||
occupant = db.query(CalendarCal).filter(
|
||||
CalendarCal.id == default_id,
|
||||
).with_for_update().first()
|
||||
if occupant is None:
|
||||
# Do not misclassify an unrelated integrity failure as an ID
|
||||
# collision and loop forever. A concurrently deleted winner is
|
||||
# safe for the caller to retry as a fresh transaction.
|
||||
raise
|
||||
if occupant.owner == owner:
|
||||
return occupant
|
||||
# A renamed calendar owns this deterministic slot. Advance to the
|
||||
# next stable slot; concurrent callers for this owner will still
|
||||
# converge there.
|
||||
collision_index += 1
|
||||
|
||||
|
||||
# Per-request user time context. chat_routes sets this from browser timezone
|
||||
@@ -1015,6 +1119,9 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_ensure_default_calendar(db, owner)
|
||||
# Listing calendars intentionally lazily creates a durable default.
|
||||
# Other callers commit it with the event they are creating.
|
||||
db.commit()
|
||||
cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all()
|
||||
return {"calendars": [
|
||||
{"name": c.name, "href": c.id, "color": c.color, "source": c.source}
|
||||
@@ -1023,6 +1130,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("Failed to list calendars: %s", e)
|
||||
raise HTTPException(500, "Failed to list calendars")
|
||||
finally:
|
||||
|
||||
+47
-100
@@ -15,7 +15,7 @@ from core.database import Session as DBSession, ModelEndpoint
|
||||
from src.llm_core import normalize_model_id
|
||||
from src.endpoint_resolver import normalize_base
|
||||
from src.context_compactor import maybe_compact, trim_for_context
|
||||
from src.model_context import estimate_tokens
|
||||
from src.model_context import estimate_tokens, get_context_length
|
||||
from src.auth_helpers import effective_user
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from src.attachment_refs import attachment_ref
|
||||
@@ -152,10 +152,38 @@ class ChatContext:
|
||||
# Uploads attached to this user turn, resolved and owner-checked for the
|
||||
# agent's private context. This is not emitted to the browser.
|
||||
uploaded_files: list = field(default_factory=list)
|
||||
# Route-neutral prompt before any model-window compaction/trimming. This is
|
||||
# retained only when explicit foreground fallbacks are enabled so each
|
||||
# concrete candidate can apply its own context budget independently.
|
||||
route_messages: list = field(default_factory=list)
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────── #
|
||||
|
||||
def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
|
||||
if privs.get("block_all_models"):
|
||||
return frozenset()
|
||||
allowed_raw = privs.get("allowed_models")
|
||||
allowed = allowed_raw if isinstance(allowed_raw, list) else []
|
||||
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
|
||||
return frozenset(model for model in allowed if isinstance(model, str)) if restricted else None
|
||||
|
||||
|
||||
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
|
||||
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
|
||||
|
||||
try:
|
||||
user = effective_user(request)
|
||||
except Exception:
|
||||
user = None
|
||||
if not user:
|
||||
return None
|
||||
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
||||
if not auth_manager:
|
||||
return None
|
||||
privs = auth_manager.get_privileges(user) or {}
|
||||
return _allowed_models_from_privileges(privs)
|
||||
|
||||
def _enforce_chat_privileges(request, sess) -> None:
|
||||
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
|
||||
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
|
||||
@@ -185,10 +213,8 @@ def _enforce_chat_privileges(request, sess) -> None:
|
||||
if privs.get("block_all_models"):
|
||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
||||
|
||||
allowed_raw = privs.get("allowed_models")
|
||||
allowed = allowed_raw if isinstance(allowed_raw, list) else []
|
||||
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
|
||||
if restricted and sess.model and sess.model not in allowed:
|
||||
allowed_models = _allowed_models_from_privileges(privs)
|
||||
if allowed_models is not None and sess.model and sess.model not in allowed_models:
|
||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
||||
|
||||
cap = int(privs.get("max_messages_per_day") or 0)
|
||||
@@ -287,96 +313,6 @@ async def auto_name_session(session_manager, sess):
|
||||
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
||||
"""Find an alternative working endpoint when the current one fails.
|
||||
|
||||
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
||||
"""
|
||||
import requests as _req
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
build_models_url,
|
||||
normalize_base,
|
||||
resolve_endpoint_runtime,
|
||||
)
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
|
||||
current_url = sess.endpoint_url or ""
|
||||
owner = getattr(sess, "owner", None)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True
|
||||
)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
endpoints = q.all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
for ep in endpoints:
|
||||
base = normalize_base(ep.base_url)
|
||||
# Skip current endpoint
|
||||
if current_url and base in current_url:
|
||||
continue
|
||||
try:
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
except Exception:
|
||||
continue
|
||||
ping_url = build_models_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
try:
|
||||
if ping_url:
|
||||
r = _req.get(ping_url, headers=headers, timeout=5)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not models:
|
||||
models = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
else:
|
||||
models = json.loads(ep.cached_models or "[]")
|
||||
if not models:
|
||||
continue
|
||||
# Found a working endpoint — update session
|
||||
new_model = models[0]
|
||||
chat_url = build_chat_url(base)
|
||||
new_headers = build_headers(api_key, base)
|
||||
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
|
||||
|
||||
sess.model = new_model
|
||||
sess.endpoint_url = chat_url
|
||||
sess.headers = new_headers
|
||||
|
||||
# Persist
|
||||
_db = SessionLocal()
|
||||
try:
|
||||
_db.query(DBSession).filter(DBSession.id == session_id).update({
|
||||
"model": new_model,
|
||||
"endpoint_url": chat_url,
|
||||
"headers": persisted_headers,
|
||||
})
|
||||
_db.commit()
|
||||
finally:
|
||||
_db.close()
|
||||
|
||||
logger.info(f"Fallback: switched session {session_id} from {current_url} to {ep.name} ({new_model})")
|
||||
return {
|
||||
"model": new_model,
|
||||
"endpoint_url": chat_url,
|
||||
"endpoint_name": ep.name,
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_preset(chat_handler, preset_id) -> PresetInfo:
|
||||
"""Extract preset parameters via chat_handler."""
|
||||
temperature, max_tokens, system_prompt, char_name = (
|
||||
@@ -687,6 +623,7 @@ async def build_chat_context(
|
||||
use_enhanced_message: bool = False,
|
||||
agent_mode: bool = False,
|
||||
allow_tool_preprocessing: bool = True,
|
||||
defer_context_shaping: bool = False,
|
||||
) -> ChatContext:
|
||||
"""Build the full context (preface + messages) for an LLM call.
|
||||
|
||||
@@ -830,13 +767,22 @@ async def build_chat_context(
|
||||
except Exception:
|
||||
logger.debug("Failed to add current date/time context", exc_info=True)
|
||||
|
||||
# Auto-compact
|
||||
messages, context_length, was_compacted = await maybe_compact(
|
||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
||||
)
|
||||
route_messages = list(messages)
|
||||
# Explicit fallback routing must shape from the same route-neutral prompt
|
||||
# for every candidate. Running selected-model compaction here would mutate
|
||||
# session history before we know which route can answer and would make a
|
||||
# later larger-context candidate unable to recover discarded history.
|
||||
if defer_context_shaping:
|
||||
context_length = get_context_length(sess.endpoint_url, sess.model)
|
||||
was_compacted = False
|
||||
else:
|
||||
messages, context_length, was_compacted = await maybe_compact(
|
||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
||||
)
|
||||
_before_trim_messages = len(messages)
|
||||
_before_trim_tokens = estimate_tokens(messages)
|
||||
messages = trim_for_context(messages, context_length)
|
||||
if not defer_context_shaping:
|
||||
messages = trim_for_context(messages, context_length)
|
||||
_after_trim_messages = len(messages)
|
||||
_after_trim_tokens = estimate_tokens(messages)
|
||||
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
|
||||
@@ -860,6 +806,7 @@ async def build_chat_context(
|
||||
context_tokens_after_trim=_after_trim_tokens,
|
||||
auto_opened_docs=auto_opened_docs,
|
||||
uploaded_files=uploaded_files,
|
||||
route_messages=route_messages,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+569
-45
@@ -15,12 +15,28 @@ from pydantic import ValidationError
|
||||
|
||||
from core.models import ChatMessage
|
||||
from src.request_models import ChatRequest
|
||||
from src.llm_core import llm_call_async, stream_llm, stream_llm_with_fallback
|
||||
from src.llm_core import (
|
||||
_normalize_http_status,
|
||||
llm_call_async,
|
||||
llm_call_async_with_route_fallback,
|
||||
stream_llm,
|
||||
stream_llm_with_fallback,
|
||||
)
|
||||
from src.agent_loop import stream_agent_loop
|
||||
from src import agent_runs
|
||||
from src.model_context import estimate_tokens
|
||||
from src.context_compactor import (
|
||||
apply_compaction_state,
|
||||
maybe_compact,
|
||||
trim_for_context,
|
||||
)
|
||||
from src.chat_helpers import coerce_message_and_session
|
||||
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
|
||||
from src.foreground_model_routing import (
|
||||
build_foreground_model_candidates,
|
||||
build_foreground_route_descriptors,
|
||||
resolve_foreground_model_policy,
|
||||
)
|
||||
from src.session_search import search_session_messages
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from core.exceptions import SessionNotFoundError
|
||||
@@ -38,7 +54,9 @@ from routes.chat_helpers import (
|
||||
build_chat_context,
|
||||
save_assistant_response,
|
||||
run_post_response_tasks,
|
||||
accumulate_token_usage,
|
||||
clean_thinking_for_save,
|
||||
_allowed_models_for_request,
|
||||
_enforce_chat_privileges,
|
||||
)
|
||||
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
|
||||
@@ -56,6 +74,74 @@ logger = logging.getLogger(__name__)
|
||||
_active_streams: Dict[str, dict] = {}
|
||||
|
||||
|
||||
def _stream_failure_status(chunk: str) -> Optional[int]:
|
||||
"""Extract a provider status without retaining provider-supplied detail."""
|
||||
|
||||
try:
|
||||
for line in str(chunk or "").splitlines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
status = json.loads(line[6:]).get("status")
|
||||
return _normalize_http_status(status)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _chat_candidate_request_factory(
|
||||
messages,
|
||||
fallback_context_length: int = 0,
|
||||
*,
|
||||
session=None,
|
||||
owner: Optional[str] = None,
|
||||
):
|
||||
"""Shape one route-neutral Chat prompt for each candidate window."""
|
||||
|
||||
state = {
|
||||
"requests": {},
|
||||
"context_lengths": {},
|
||||
"trim_stats": {},
|
||||
"compactions": {},
|
||||
"was_compacted": {},
|
||||
}
|
||||
|
||||
async def factory(index, candidate_url, candidate_model, candidate_headers):
|
||||
compaction_state = {}
|
||||
candidate_messages, context_length, was_compacted = await maybe_compact(
|
||||
session,
|
||||
candidate_url,
|
||||
candidate_model,
|
||||
list(messages),
|
||||
candidate_headers,
|
||||
owner=owner,
|
||||
persist=False,
|
||||
compaction_state=compaction_state,
|
||||
)
|
||||
if not context_length:
|
||||
context_length = fallback_context_length
|
||||
request_messages = trim_for_context(candidate_messages, context_length)
|
||||
state["requests"][index] = request_messages
|
||||
state["context_lengths"][index] = context_length
|
||||
state["compactions"][index] = compaction_state
|
||||
state["was_compacted"][index] = was_compacted
|
||||
state["trim_stats"][index] = {
|
||||
"messages_before": len(messages),
|
||||
"messages_after": len(request_messages),
|
||||
"tokens_before": estimate_tokens(messages),
|
||||
"tokens_after": estimate_tokens(request_messages),
|
||||
}
|
||||
return {"messages": request_messages}
|
||||
|
||||
return factory, state
|
||||
|
||||
|
||||
def _candidate_index(candidates, actual_candidate) -> int:
|
||||
for index, candidate in enumerate(candidates):
|
||||
if candidate == actual_candidate:
|
||||
return index
|
||||
return 0
|
||||
|
||||
|
||||
def _stream_set(session_id: str, **fields) -> None:
|
||||
"""Update fields on the active-stream entry for `session_id`, or
|
||||
no-op if the entry has already been popped. Using .get() avoids a
|
||||
@@ -589,8 +675,8 @@ def setup_chat_routes(
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/chat (non-streaming)
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.post("/api/chat", response_model=Dict[str, str])
|
||||
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]:
|
||||
@router.post("/api/chat", response_model=Dict[str, Any])
|
||||
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]:
|
||||
_set_user_time_from_request(request)
|
||||
|
||||
message = chat_request.message
|
||||
@@ -622,6 +708,8 @@ def setup_chat_routes(
|
||||
400,
|
||||
"No model selected for this chat. Open the model picker and choose one before sending.",
|
||||
)
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
|
||||
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
|
||||
# non-streaming path can't be used to bypass).
|
||||
@@ -637,6 +725,11 @@ def setup_chat_routes(
|
||||
if memory_response:
|
||||
return {"response": memory_response}
|
||||
|
||||
foreground_policy = resolve_foreground_model_policy(
|
||||
owner=owner,
|
||||
allowed_models=_allowed_models_for_request(request),
|
||||
)
|
||||
|
||||
# Build shared context (preset, preprocess, preface, compact)
|
||||
ctx = await build_chat_context(
|
||||
sess, request, chat_handler, chat_processor,
|
||||
@@ -648,6 +741,7 @@ def setup_chat_routes(
|
||||
time_filter=time_filter,
|
||||
webhook_manager=webhook_manager,
|
||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||
defer_context_shaping=foreground_policy.enabled,
|
||||
)
|
||||
|
||||
# Research injection
|
||||
@@ -661,24 +755,88 @@ def setup_chat_routes(
|
||||
research_ctx = await research_handler.call_research_service(
|
||||
message, _r_ep, _r_model, llm_headers=_r_headers
|
||||
)
|
||||
ctx.messages.insert(
|
||||
len(ctx.preface),
|
||||
untrusted_context_message("research context", research_ctx),
|
||||
)
|
||||
research_message = untrusted_context_message("research context", research_ctx)
|
||||
ctx.messages.insert(len(ctx.preface), research_message)
|
||||
if foreground_policy.enabled:
|
||||
getattr(ctx, "route_messages", ctx.messages).insert(
|
||||
len(ctx.preface),
|
||||
research_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Research failed: {e}")
|
||||
|
||||
reply = await llm_call_async(
|
||||
foreground_candidates = build_foreground_model_candidates(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
ctx.messages,
|
||||
headers=sess.headers,
|
||||
sess.headers,
|
||||
owner=owner,
|
||||
policy=foreground_policy,
|
||||
)
|
||||
route_descriptors = build_foreground_route_descriptors(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
sess.headers,
|
||||
owner=owner,
|
||||
policy=foreground_policy,
|
||||
selected_endpoint_id=chat_request.selected_endpoint_id,
|
||||
)
|
||||
candidate_request_factory = None
|
||||
selected_context_length = getattr(ctx, "context_length", 0)
|
||||
candidate_request_state = {
|
||||
"context_lengths": {0: selected_context_length},
|
||||
"requests": {0: ctx.messages},
|
||||
"trim_stats": {},
|
||||
}
|
||||
request_messages = ctx.messages
|
||||
if foreground_policy.enabled:
|
||||
request_messages = getattr(ctx, "route_messages", ctx.messages)
|
||||
candidate_request_factory, candidate_request_state = _chat_candidate_request_factory(
|
||||
request_messages,
|
||||
selected_context_length,
|
||||
session=sess,
|
||||
owner=owner,
|
||||
)
|
||||
requested_model = sess.model
|
||||
reply, actual_candidate, actual_model = await llm_call_async_with_route_fallback(
|
||||
foreground_candidates,
|
||||
request_messages,
|
||||
fallback_statuses=foreground_policy.eligible_statuses,
|
||||
candidate_request_factory=candidate_request_factory,
|
||||
temperature=ctx.preset.temperature,
|
||||
max_tokens=ctx.preset.max_tokens,
|
||||
prompt_type=preset_id,
|
||||
session_id=session,
|
||||
)
|
||||
_clean_reply, _clean_md = clean_thinking_for_save(reply, {"model": sess.model})
|
||||
actual_index = _candidate_index(foreground_candidates, actual_candidate)
|
||||
apply_compaction_state(
|
||||
sess,
|
||||
candidate_request_state.get("compactions", {}).get(actual_index),
|
||||
)
|
||||
requested_route = route_descriptors[0]
|
||||
actual_route = route_descriptors[actual_index]
|
||||
actual_trim = candidate_request_state.get("trim_stats", {}).get(actual_index, {})
|
||||
_clean_reply, _clean_md = clean_thinking_for_save(
|
||||
reply,
|
||||
{
|
||||
"model": actual_model,
|
||||
"requested_model": requested_model,
|
||||
"endpoint_id": actual_route.get("endpoint_id"),
|
||||
"endpoint_label": actual_route.get("endpoint_label"),
|
||||
"requested_endpoint_id": requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": requested_route.get("endpoint_label"),
|
||||
"context_length": candidate_request_state["context_lengths"].get(
|
||||
actual_index,
|
||||
selected_context_length,
|
||||
),
|
||||
"context_trimmed": bool(
|
||||
actual_trim
|
||||
and (
|
||||
actual_trim.get("messages_after") < actual_trim.get("messages_before")
|
||||
or actual_trim.get("tokens_after") < actual_trim.get("tokens_before")
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md))
|
||||
|
||||
from core.database import update_session_last_accessed
|
||||
@@ -694,7 +852,15 @@ def setup_chat_routes(
|
||||
allow_background_extraction=not tool_policy.block_all_tool_calls,
|
||||
)
|
||||
|
||||
return {"response": reply}
|
||||
return {
|
||||
"response": reply,
|
||||
"requested_model": requested_model,
|
||||
"model": actual_model,
|
||||
"requested_endpoint_id": requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": requested_route.get("endpoint_label"),
|
||||
"endpoint_id": actual_route.get("endpoint_id"),
|
||||
"endpoint_label": actual_route.get("endpoint_label"),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/chat_stream
|
||||
@@ -723,6 +889,11 @@ def setup_chat_routes(
|
||||
use_research = form_data.get("use_research")
|
||||
time_filter = form_data.get("time_filter")
|
||||
preset_id = form_data.get("preset_id")
|
||||
selected_endpoint_id = str(
|
||||
form_data.get("selected_endpoint_id")
|
||||
or (body or {}).get("selected_endpoint_id")
|
||||
or ""
|
||||
).strip()
|
||||
# Issue #3229: API callers send JSON, not FormData. Read from the
|
||||
# JSON body as fallback so callers who send {"allow_bash": true}
|
||||
# actually get bash enabled.
|
||||
@@ -895,6 +1066,8 @@ def setup_chat_routes(
|
||||
400,
|
||||
"No model selected for this chat. Open the model picker and choose one before sending.",
|
||||
)
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
if (
|
||||
chat_mode == "chat"
|
||||
and isinstance(message, str)
|
||||
@@ -970,6 +1143,10 @@ def setup_chat_routes(
|
||||
last_user_message=message,
|
||||
)
|
||||
allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls
|
||||
foreground_policy = resolve_foreground_model_policy(
|
||||
owner=owner,
|
||||
allowed_models=_allowed_models_for_request(request),
|
||||
)
|
||||
|
||||
# Build shared context (stream path uses enhanced_message for context preface)
|
||||
ctx = await build_chat_context(
|
||||
@@ -992,6 +1169,7 @@ def setup_chat_routes(
|
||||
# index would be useless / unwanted noise.
|
||||
agent_mode=(chat_mode == "agent"),
|
||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||
defer_context_shaping=foreground_policy.enabled,
|
||||
)
|
||||
|
||||
_research_flags = {"do": do_research} # Mutable container for generator scope
|
||||
@@ -1291,6 +1469,8 @@ def setup_chat_routes(
|
||||
"what aspects matter most, are they comparing to something, what's their context "
|
||||
"(moving, traveling, curiosity). Be conversational. Keep it short."
|
||||
})
|
||||
if foreground_policy.enabled:
|
||||
getattr(ctx, "route_messages", ctx.messages).insert(0, dict(ctx.messages[0]))
|
||||
_skip_research = True
|
||||
else:
|
||||
_skip_research = False
|
||||
@@ -1387,7 +1567,12 @@ def setup_chat_routes(
|
||||
_active_streams.pop(session, None)
|
||||
return
|
||||
|
||||
messages = _ensure_current_request_is_latest_user(ctx.messages, message)
|
||||
context_source = (
|
||||
getattr(ctx, "route_messages", ctx.messages)
|
||||
if foreground_policy.enabled
|
||||
else ctx.messages
|
||||
)
|
||||
messages = _ensure_current_request_is_latest_user(context_source, message)
|
||||
|
||||
# Auto-compact notification
|
||||
if ctx.was_compacted:
|
||||
@@ -1399,25 +1584,56 @@ def setup_chat_routes(
|
||||
thinking_response = ""
|
||||
last_metrics = None
|
||||
|
||||
# Configured fallback chain for the default chat model. Tried in
|
||||
# order if the session's primary model fails before producing
|
||||
# output. Resolved once per request.
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_chat_fallback_candidates
|
||||
_fallback_candidates = resolve_chat_fallback_candidates(owner=_user)
|
||||
except Exception:
|
||||
_fallback_candidates = []
|
||||
# Foreground Chat and Agent requests share one explicit owner-aware
|
||||
# policy. Strict mode is the default; legacy values are unrelated.
|
||||
_foreground_policy = foreground_policy
|
||||
_foreground_candidates = build_foreground_model_candidates(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
sess.headers,
|
||||
owner=_user,
|
||||
policy=_foreground_policy,
|
||||
)
|
||||
_foreground_route_descriptors = build_foreground_route_descriptors(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
sess.headers,
|
||||
owner=_user,
|
||||
policy=_foreground_policy,
|
||||
selected_endpoint_id=selected_endpoint_id,
|
||||
)
|
||||
_chat_request_factory = None
|
||||
_selected_context_length = getattr(ctx, "context_length", 0)
|
||||
_chat_request_state = {
|
||||
"context_lengths": {0: _selected_context_length},
|
||||
"requests": {0: messages},
|
||||
"trim_stats": {},
|
||||
}
|
||||
if _foreground_policy.enabled:
|
||||
_chat_request_factory, _chat_request_state = _chat_candidate_request_factory(
|
||||
messages,
|
||||
_selected_context_length,
|
||||
session=sess,
|
||||
owner=_user,
|
||||
)
|
||||
|
||||
# Send model name early so the frontend can show it during streaming
|
||||
_model_suffix = "Research" if effective_do_research else None
|
||||
_model_info = {"type": "model_info", "model": sess.model}
|
||||
_selected_route = _foreground_route_descriptors[0]
|
||||
_model_info = {
|
||||
"type": "model_info",
|
||||
"model": sess.model,
|
||||
"endpoint_id": _selected_route.get("endpoint_id"),
|
||||
"endpoint_label": _selected_route.get("endpoint_label"),
|
||||
}
|
||||
if _model_suffix:
|
||||
_model_info["suffix"] = _model_suffix
|
||||
if ctx.preset.character_name:
|
||||
_model_info["character_name"] = ctx.preset.character_name
|
||||
yield f'data: {json.dumps(_model_info)}\n\n'
|
||||
|
||||
if image_generation_session:
|
||||
_terminal_saved = False
|
||||
if _is_image_generation_session(sess, owner=_user):
|
||||
from src.settings import get_setting
|
||||
if tool_policy.blocks("generate_image"):
|
||||
_blocked_msg = tool_policy.reason_for("generate_image")
|
||||
@@ -1520,11 +1736,20 @@ def setup_chat_routes(
|
||||
_answered_by = None # set if the selected model failed and a fallback answered
|
||||
_requested_model = sess.model
|
||||
_actual_model = None
|
||||
_requested_route = _foreground_route_descriptors[0]
|
||||
_actual_route = _requested_route
|
||||
_actual_candidate_index = 0
|
||||
_chat_terminal_saved = False
|
||||
def _commit_chat_compaction(candidate_index: int) -> bool:
|
||||
return apply_compaction_state(
|
||||
sess,
|
||||
_chat_request_state.get("compactions", {}).get(candidate_index),
|
||||
)
|
||||
|
||||
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
||||
try:
|
||||
_chat_candidates = [(sess.endpoint_url, sess.model, sess.headers)] + _fallback_candidates
|
||||
async for chunk in stream_llm_with_fallback(
|
||||
_chat_candidates,
|
||||
_foreground_candidates,
|
||||
messages,
|
||||
temperature=ctx.preset.temperature,
|
||||
# Respect the preset; 0/unset = let the server decide (no
|
||||
@@ -1536,11 +1761,21 @@ def setup_chat_routes(
|
||||
prompt_type=preset_id,
|
||||
tools=None,
|
||||
session_id=session,
|
||||
fallback_statuses=_foreground_policy.eligible_statuses,
|
||||
fallback_on_empty=_foreground_policy.fallback_on_empty,
|
||||
candidate_request_factory=_chat_request_factory,
|
||||
candidate_route_descriptors=_foreground_route_descriptors,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
data = json.loads(chunk[6:])
|
||||
if "delta" in data:
|
||||
if _commit_chat_compaction(_actual_candidate_index):
|
||||
_compacted_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
|
||||
# Reasoning tokens arrive flagged thinking:true.
|
||||
# Forward them so the client can show a thinking
|
||||
# indicator, but don't fold them into the saved
|
||||
@@ -1556,29 +1791,82 @@ def setup_chat_routes(
|
||||
# Forward the notice and remember the real model.
|
||||
_answered_by = data.get("answered_by") or _answered_by
|
||||
_actual_model = _actual_model or _answered_by
|
||||
_actual_candidate_index = data.get("candidate_index", 0)
|
||||
if not isinstance(_actual_candidate_index, int):
|
||||
_actual_candidate_index = 0
|
||||
if 0 <= _actual_candidate_index < len(_foreground_route_descriptors):
|
||||
_actual_route = _foreground_route_descriptors[_actual_candidate_index]
|
||||
if _commit_chat_compaction(_actual_candidate_index):
|
||||
_compacted_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
|
||||
data["selected_model"] = data.get("selected_model") or _requested_model
|
||||
yield chunk
|
||||
yield f'data: {json.dumps(data)}\n\n'
|
||||
elif data.get("type") == "model_actual":
|
||||
if _commit_chat_compaction(_actual_candidate_index):
|
||||
_compacted_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
|
||||
_actual_model = data.get("model") or _actual_model
|
||||
data["requested_model"] = _requested_model
|
||||
data["requested_endpoint_id"] = _requested_route.get("endpoint_id")
|
||||
data["requested_endpoint_label"] = _requested_route.get("endpoint_label")
|
||||
data["endpoint_id"] = _actual_route.get("endpoint_id")
|
||||
data["endpoint_label"] = _actual_route.get("endpoint_label")
|
||||
yield f'data: {json.dumps(data)}\n\n'
|
||||
elif data.get("type") == "usage":
|
||||
if _commit_chat_compaction(_actual_candidate_index):
|
||||
_compacted_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
|
||||
last_metrics = data.get("data", {})
|
||||
_reported_model = last_metrics.get("model")
|
||||
last_metrics["requested_model"] = _requested_model
|
||||
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
|
||||
if ctx.context_trimmed:
|
||||
last_metrics["requested_endpoint_id"] = _requested_route.get("endpoint_id")
|
||||
last_metrics["requested_endpoint_label"] = _requested_route.get("endpoint_label")
|
||||
last_metrics["endpoint_id"] = _actual_route.get("endpoint_id")
|
||||
last_metrics["endpoint_label"] = _actual_route.get("endpoint_label")
|
||||
if isinstance(
|
||||
_actual_route.get("endpoint_cost_tracked"),
|
||||
bool,
|
||||
):
|
||||
last_metrics["endpoint_cost_tracked"] = _actual_route.get(
|
||||
"endpoint_cost_tracked"
|
||||
)
|
||||
_actual_context_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
_route_trim = _chat_request_state.get("trim_stats", {}).get(
|
||||
_actual_candidate_index,
|
||||
{},
|
||||
)
|
||||
if _route_trim and (
|
||||
_route_trim.get("messages_after") < _route_trim.get("messages_before")
|
||||
or _route_trim.get("tokens_after") < _route_trim.get("tokens_before")
|
||||
):
|
||||
last_metrics["context_trimmed"] = True
|
||||
last_metrics["context_messages_before_trim"] = _route_trim.get("messages_before")
|
||||
last_metrics["context_messages_after_trim"] = _route_trim.get("messages_after")
|
||||
last_metrics["context_tokens_before_trim"] = _route_trim.get("tokens_before")
|
||||
last_metrics["context_tokens_after_trim"] = _route_trim.get("tokens_after")
|
||||
elif ctx.context_trimmed:
|
||||
last_metrics["context_trimmed"] = True
|
||||
last_metrics["context_messages_before_trim"] = ctx.context_messages_before_trim
|
||||
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
|
||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
||||
request_context_tokens = ctx.context_tokens_after_trim or estimate_tokens(messages)
|
||||
last_metrics["request_context_tokens"] = request_context_tokens
|
||||
if ctx.context_length and request_context_tokens:
|
||||
pct = min(round((request_context_tokens / ctx.context_length) * 100, 1), 100.0)
|
||||
if _actual_context_length and last_metrics.get("input_tokens"):
|
||||
pct = min(round((last_metrics["input_tokens"] / _actual_context_length) * 100, 1), 100.0)
|
||||
last_metrics["context_percent"] = pct
|
||||
last_metrics["context_length"] = ctx.context_length
|
||||
last_metrics["context_length"] = _actual_context_length
|
||||
# The frontend reads `tokens_per_second`; the raw usage event
|
||||
# carries the backend's true gen speed as `gen_tps` (llama.cpp
|
||||
# timings). Map it through so this direct-chat path shows real
|
||||
@@ -1593,17 +1881,121 @@ def setup_chat_routes(
|
||||
yield chunk
|
||||
elif chunk.startswith("event: error"):
|
||||
logger.warning(f"Stream error for {sess.model} on {sess.endpoint_url}: {chunk!r}")
|
||||
if (
|
||||
not _chat_terminal_saved
|
||||
and (full_response.strip() or thinking_response.strip())
|
||||
):
|
||||
_failure_status = _stream_failure_status(chunk)
|
||||
_failure_message = (
|
||||
f"Model request failed (HTTP {_failure_status})"
|
||||
if _failure_status is not None
|
||||
else "Model request failed"
|
||||
)
|
||||
_terminal_content = full_response.strip()
|
||||
_failure_note = f"[Response stopped: {_failure_message}]"
|
||||
_terminal_content = (
|
||||
f"{_terminal_content}\n\n{_failure_note}"
|
||||
if _terminal_content
|
||||
else _failure_note
|
||||
)
|
||||
_had_terminal_usage = bool(last_metrics)
|
||||
_terminal_metrics = dict(last_metrics or {})
|
||||
if not _had_terminal_usage:
|
||||
_actual_request_messages = _chat_request_state["requests"].get(
|
||||
_actual_candidate_index,
|
||||
messages,
|
||||
)
|
||||
_actual_context_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
_estimated_input = estimate_tokens(_actual_request_messages)
|
||||
_estimated_output = max(
|
||||
len(full_response + thinking_response) // 4,
|
||||
0,
|
||||
)
|
||||
_terminal_metrics.update({
|
||||
"input_tokens": _estimated_input,
|
||||
"output_tokens": _estimated_output,
|
||||
"total_tokens": _estimated_input + _estimated_output,
|
||||
"usage_source": "estimated",
|
||||
"response_time": round(time.time() - _chat_start, 2),
|
||||
"context_length": _actual_context_length,
|
||||
"context_percent": (
|
||||
min(
|
||||
round(
|
||||
(_estimated_input / _actual_context_length) * 100,
|
||||
1,
|
||||
),
|
||||
100.0,
|
||||
)
|
||||
if _actual_context_length
|
||||
else 0
|
||||
),
|
||||
})
|
||||
_terminal_metrics.update({
|
||||
"failed": True,
|
||||
"failure": {
|
||||
"status": _failure_status,
|
||||
"message": _failure_message,
|
||||
},
|
||||
"model": _actual_model or _answered_by or _requested_model,
|
||||
"requested_model": _requested_model,
|
||||
"endpoint_id": _actual_route.get("endpoint_id"),
|
||||
"endpoint_label": _actual_route.get("endpoint_label"),
|
||||
"requested_endpoint_id": _requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": _requested_route.get("endpoint_label"),
|
||||
})
|
||||
if isinstance(
|
||||
_actual_route.get("endpoint_cost_tracked"),
|
||||
bool,
|
||||
):
|
||||
_terminal_metrics["endpoint_cost_tracked"] = _actual_route.get(
|
||||
"endpoint_cost_tracked"
|
||||
)
|
||||
if thinking_response.strip():
|
||||
_terminal_metrics["thinking"] = thinking_response.strip()
|
||||
_commit_chat_compaction(_actual_candidate_index)
|
||||
_saved_id = save_assistant_response(
|
||||
sess,
|
||||
session_manager,
|
||||
session,
|
||||
_terminal_content,
|
||||
_terminal_metrics,
|
||||
character_name=ctx.preset.character_name,
|
||||
incognito=incognito,
|
||||
)
|
||||
accumulate_token_usage(session, _terminal_metrics)
|
||||
_chat_terminal_saved = True
|
||||
_stream_set(session, status="error")
|
||||
if _saved_id:
|
||||
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
||||
yield f'data: {json.dumps({"type": "chat_terminal", "data": _terminal_metrics})}\n\n'
|
||||
yield chunk
|
||||
elif chunk.startswith("event: "):
|
||||
yield chunk
|
||||
elif chunk == "data: [DONE]\n\n":
|
||||
if _chat_terminal_saved:
|
||||
# Some providers append DONE after a terminal
|
||||
# error. The failed partial is already saved;
|
||||
# never re-save/post-process it as a success or
|
||||
# advertise successful completion to the client.
|
||||
continue
|
||||
# Generate fallback metrics if LLM didn't send usage
|
||||
if not last_metrics and full_response:
|
||||
_elapsed = time.time() - _chat_start
|
||||
_est_in = estimate_tokens(messages)
|
||||
_est_out = len(full_response) // 4
|
||||
_tps = round(_est_out / _elapsed, 2) if _elapsed > 0 else 0
|
||||
_ctx_pct = min(round((_est_in / ctx.context_length) * 100, 1), 100.0) if ctx.context_length else 0
|
||||
_actual_context_length = _chat_request_state["context_lengths"].get(
|
||||
_actual_candidate_index,
|
||||
_selected_context_length,
|
||||
)
|
||||
_actual_request_messages = _chat_request_state["requests"].get(
|
||||
_actual_candidate_index,
|
||||
messages,
|
||||
)
|
||||
_est_in = estimate_tokens(_actual_request_messages)
|
||||
_ctx_pct = min(round((_est_in / _actual_context_length) * 100, 1), 100.0) if _actual_context_length else 0
|
||||
last_metrics = {
|
||||
"response_time": round(_elapsed, 2),
|
||||
"input_tokens": _est_in,
|
||||
@@ -1611,13 +2003,25 @@ def setup_chat_routes(
|
||||
"tokens_per_second": _tps,
|
||||
"request_context_tokens": _est_in,
|
||||
"context_percent": _ctx_pct,
|
||||
"context_length": ctx.context_length,
|
||||
"context_length": _actual_context_length,
|
||||
"model": _actual_model or _answered_by or _requested_model,
|
||||
"requested_model": _requested_model,
|
||||
"requested_endpoint_id": _requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": _requested_route.get("endpoint_label"),
|
||||
"endpoint_id": _actual_route.get("endpoint_id"),
|
||||
"endpoint_label": _actual_route.get("endpoint_label"),
|
||||
"usage_source": "estimated",
|
||||
}
|
||||
if isinstance(
|
||||
_actual_route.get("endpoint_cost_tracked"),
|
||||
bool,
|
||||
):
|
||||
last_metrics["endpoint_cost_tracked"] = _actual_route.get(
|
||||
"endpoint_cost_tracked"
|
||||
)
|
||||
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
||||
if full_response:
|
||||
_commit_chat_compaction(_actual_candidate_index)
|
||||
_metrics_to_save = dict(last_metrics or {})
|
||||
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
|
||||
_metrics_to_save["thinking"] = thinking_response.strip()
|
||||
@@ -1652,6 +2056,10 @@ def setup_chat_routes(
|
||||
"stopped": True,
|
||||
"model": _actual_model or _answered_by or _requested_model,
|
||||
"requested_model": _requested_model,
|
||||
"endpoint_id": _actual_route.get("endpoint_id"),
|
||||
"endpoint_label": _actual_route.get("endpoint_label"),
|
||||
"requested_endpoint_id": _requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": _requested_route.get("endpoint_label"),
|
||||
},
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
|
||||
@@ -1666,6 +2074,12 @@ def setup_chat_routes(
|
||||
_answered_by = None # set if the selected model failed and a fallback answered
|
||||
_requested_model = sess.model
|
||||
_actual_model = None
|
||||
_agent_requested_route = _foreground_route_descriptors[0]
|
||||
_agent_actual_endpoint_id = _agent_requested_route.get("endpoint_id")
|
||||
_agent_actual_endpoint_label = _agent_requested_route.get("endpoint_label")
|
||||
_agent_round_models = {1: _requested_model}
|
||||
_agent_round_endpoint_ids = {1: _agent_actual_endpoint_id}
|
||||
_agent_round_endpoint_labels = {1: _agent_actual_endpoint_label}
|
||||
try:
|
||||
from src.settings import get_setting
|
||||
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
|
||||
@@ -1703,19 +2117,24 @@ def setup_chat_routes(
|
||||
prompt_type=preset_id,
|
||||
max_tool_calls=_tool_budget,
|
||||
max_rounds=_max_rounds,
|
||||
context_length=ctx.context_length,
|
||||
context_length=_selected_context_length,
|
||||
active_document=active_doc,
|
||||
active_email=active_email_ctx,
|
||||
session_id=session,
|
||||
history_session=sess,
|
||||
disabled_tools=disabled_tools if disabled_tools else None,
|
||||
tool_policy=tool_policy,
|
||||
owner=_user,
|
||||
fallbacks=_fallback_candidates,
|
||||
fallbacks=_foreground_candidates[1:],
|
||||
route_descriptors=_foreground_route_descriptors,
|
||||
fallback_statuses=_foreground_policy.eligible_statuses,
|
||||
fallback_on_empty=_foreground_policy.fallback_on_empty,
|
||||
plan_mode=plan_mode,
|
||||
approved_plan=approved_plan or None,
|
||||
workspace=workspace or None,
|
||||
forced_tools=_forced_tools,
|
||||
uploaded_files=ctx.uploaded_files,
|
||||
defer_context_shaping=_foreground_policy.enabled,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
@@ -1744,7 +2163,20 @@ def setup_chat_routes(
|
||||
"plan_update",
|
||||
):
|
||||
if data.get("type") == "agent_step":
|
||||
_agent_rounds = max(_agent_rounds, data.get("round", 1))
|
||||
_event_round = data.get("round", 1)
|
||||
_agent_rounds = max(_agent_rounds, _event_round)
|
||||
_agent_round_models.setdefault(
|
||||
_event_round,
|
||||
_actual_model or _answered_by or _requested_model,
|
||||
)
|
||||
_agent_round_endpoint_ids.setdefault(
|
||||
_event_round,
|
||||
_agent_actual_endpoint_id,
|
||||
)
|
||||
_agent_round_endpoint_labels.setdefault(
|
||||
_event_round,
|
||||
_agent_actual_endpoint_label,
|
||||
)
|
||||
elif data.get("type") == "tool_start":
|
||||
_agent_tool_calls += 1
|
||||
yield chunk
|
||||
@@ -1754,13 +2186,70 @@ def setup_chat_routes(
|
||||
# model so metrics reflect it, not the masked
|
||||
# selected model.
|
||||
_answered_by = data.get("answered_by") or _answered_by
|
||||
_actual_model = _actual_model or _answered_by
|
||||
_actual_model = _answered_by or _actual_model
|
||||
if "answered_by_endpoint_id" in data:
|
||||
_agent_actual_endpoint_id = data.get("answered_by_endpoint_id")
|
||||
if data.get("answered_by_endpoint_label"):
|
||||
_agent_actual_endpoint_label = data.get("answered_by_endpoint_label")
|
||||
_event_round = data.get("round") or max(_agent_rounds, 1)
|
||||
_agent_round_models[_event_round] = _answered_by or _requested_model
|
||||
_agent_round_endpoint_ids[_event_round] = _agent_actual_endpoint_id
|
||||
_agent_round_endpoint_labels[_event_round] = _agent_actual_endpoint_label
|
||||
data["selected_model"] = data.get("selected_model") or _requested_model
|
||||
yield chunk
|
||||
elif data.get("type") == "model_actual":
|
||||
_actual_model = data.get("model") or _actual_model
|
||||
if "endpoint_id" in data:
|
||||
_agent_actual_endpoint_id = data.get("endpoint_id")
|
||||
if data.get("endpoint_label"):
|
||||
_agent_actual_endpoint_label = data.get("endpoint_label")
|
||||
_event_round = data.get("round") or max(_agent_rounds, 1)
|
||||
_agent_round_models[_event_round] = _actual_model or _requested_model
|
||||
_agent_round_endpoint_ids[_event_round] = _agent_actual_endpoint_id
|
||||
_agent_round_endpoint_labels[_event_round] = _agent_actual_endpoint_label
|
||||
data["requested_model"] = _requested_model
|
||||
yield f'data: {json.dumps(data)}\n\n'
|
||||
elif data.get("type") == "agent_terminal":
|
||||
terminal_metadata = dict(data.get("data") or {})
|
||||
last_metrics = terminal_metadata
|
||||
failure = terminal_metadata.get("failure") or {}
|
||||
failure_status = _normalize_http_status(
|
||||
failure.get("status")
|
||||
)
|
||||
failure_message = (
|
||||
f"Model request failed (HTTP {failure_status})"
|
||||
if failure_status is not None
|
||||
else "Model request failed"
|
||||
)
|
||||
terminal_metadata["failure"] = {
|
||||
"status": failure_status,
|
||||
"message": failure_message,
|
||||
}
|
||||
terminal_content = full_response.strip()
|
||||
failure_note = f"[Agent stopped: {failure_message}]"
|
||||
if terminal_content:
|
||||
terminal_content = f"{terminal_content}\n\n{failure_note}"
|
||||
else:
|
||||
terminal_content = failure_note
|
||||
if not _terminal_saved:
|
||||
_saved_id = save_assistant_response(
|
||||
sess,
|
||||
session_manager,
|
||||
session,
|
||||
terminal_content,
|
||||
terminal_metadata,
|
||||
character_name=ctx.preset.character_name,
|
||||
web_sources=web_sources,
|
||||
rag_sources=ctx.rag_sources,
|
||||
used_memories=ctx.used_memories,
|
||||
incognito=incognito,
|
||||
)
|
||||
_terminal_saved = True
|
||||
accumulate_token_usage(session, terminal_metadata)
|
||||
_stream_set(session, status="error")
|
||||
if _saved_id:
|
||||
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
|
||||
yield chunk
|
||||
elif data.get("type") == "metrics":
|
||||
last_metrics = data.get("data", {})
|
||||
_reported_model = last_metrics.get("model")
|
||||
@@ -1772,7 +2261,16 @@ def setup_chat_routes(
|
||||
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
|
||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
||||
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
||||
_metrics_event = {"type": "metrics", "data": last_metrics}
|
||||
# Inline teacher escalation marks its
|
||||
# recursively emitted events at the SSE
|
||||
# envelope. Preserve that non-secret marker
|
||||
# when normalizing metrics so the browser's
|
||||
# replay-stable ledger keeps primary and
|
||||
# teacher segments distinct.
|
||||
if data.get("teacher") is True:
|
||||
_metrics_event["teacher"] = True
|
||||
yield f'data: {json.dumps(_metrics_event)}\n\n'
|
||||
except json.JSONDecodeError:
|
||||
yield chunk
|
||||
elif chunk.startswith("event: "):
|
||||
@@ -1824,6 +2322,22 @@ def setup_chat_routes(
|
||||
"stopped": True,
|
||||
"model": _actual_model or _answered_by or _requested_model,
|
||||
"requested_model": _requested_model,
|
||||
"endpoint_id": _agent_actual_endpoint_id,
|
||||
"endpoint_label": _agent_actual_endpoint_label,
|
||||
"requested_endpoint_id": _agent_requested_route.get("endpoint_id"),
|
||||
"requested_endpoint_label": _agent_requested_route.get("endpoint_label"),
|
||||
"round_models": [
|
||||
_agent_round_models.get(i, _actual_model or _requested_model)
|
||||
for i in range(1, max(_agent_round_models, default=1) + 1)
|
||||
],
|
||||
"round_endpoint_ids": [
|
||||
_agent_round_endpoint_ids.get(i)
|
||||
for i in range(1, max(_agent_round_models, default=1) + 1)
|
||||
],
|
||||
"round_endpoint_labels": [
|
||||
_agent_round_endpoint_labels.get(i)
|
||||
for i in range(1, max(_agent_round_models, default=1) + 1)
|
||||
],
|
||||
},
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
|
||||
@@ -1866,8 +2380,12 @@ def setup_chat_routes(
|
||||
if compare_mode:
|
||||
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
|
||||
|
||||
agent_runs.start(session, _safe_stream())
|
||||
return StreamingResponse(agent_runs.subscribe(session), media_type="text/event-stream")
|
||||
_detached_run = agent_runs.start(session, _safe_stream())
|
||||
return StreamingResponse(
|
||||
agent_runs.subscribe(session, _detached_run),
|
||||
media_type="text/event-stream",
|
||||
headers={"X-Odysseus-Run-Id": _detached_run.run_id},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# GET /api/chat/resume — reconnect to a detached run that's still going
|
||||
@@ -1876,9 +2394,14 @@ def setup_chat_routes(
|
||||
@router.get("/api/chat/resume/{session_id}")
|
||||
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
|
||||
_verify_session_owner(request, session_id)
|
||||
if not agent_runs.is_active(session_id):
|
||||
_active_run = agent_runs.get_active_run(session_id)
|
||||
if _active_run is None:
|
||||
raise HTTPException(404, "No active run for this session")
|
||||
return StreamingResponse(agent_runs.subscribe(session_id), media_type="text/event-stream")
|
||||
return StreamingResponse(
|
||||
agent_runs.subscribe(session_id, _active_run),
|
||||
media_type="text/event-stream",
|
||||
headers={"X-Odysseus-Run-Id": _active_run.run_id},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE
|
||||
@@ -1887,7 +2410,8 @@ def setup_chat_routes(
|
||||
@router.post("/api/chat/stop/{session_id}")
|
||||
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
|
||||
_verify_session_owner(request, session_id)
|
||||
stopped = agent_runs.stop(session_id)
|
||||
_expected_run_id = request.headers.get("X-Odysseus-Run-Id")
|
||||
stopped = agent_runs.stop(session_id, _expected_run_id)
|
||||
return {"stopped": stopped}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -73,6 +73,30 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
||||
)
|
||||
|
||||
|
||||
def _windows_local_pid_record_line(pid_path: Path, ready_path: Path) -> str:
|
||||
"""Build the Git Bash prelude that records a Win32-stoppable PID.
|
||||
|
||||
Python publishes the detached outer process's Win32 PID first, then touches
|
||||
``ready_path``. The inner Git Bash runner waits for that publication before
|
||||
replacing the fallback with its own Win32 PID from /proc/<msys-pid>/winpid.
|
||||
|
||||
Missing, malformed, or late mappings leave the valid outer PID untouched.
|
||||
"""
|
||||
pp = shlex.quote(pid_path.as_posix())
|
||||
rp = shlex.quote(ready_path.as_posix())
|
||||
return (
|
||||
"i=0; "
|
||||
f"while [ ! -e {rp} ] && [ \"$i\" -lt 500 ]; do "
|
||||
"i=$((i+1)); sleep 0.01; done; "
|
||||
f"if [ -e {rp} ]; then "
|
||||
"winpid=\"$(cat /proc/$$/winpid 2>/dev/null || true)\"; "
|
||||
"case \"$winpid\" in ''|*[!0-9]*) ;; "
|
||||
f"*) printf '%s\\n' \"$winpid\" > {pp} ;; esac; "
|
||||
"fi; "
|
||||
f"rm -f {rp}"
|
||||
)
|
||||
|
||||
|
||||
def _append_mlx_image_server_script(runner_lines: list[str]) -> None:
|
||||
"""Write the MLX image API helper next to the tmux runner on remote hosts."""
|
||||
script_path = Path(__file__).resolve().parents[1] / "scripts" / "mlx_image_server.py"
|
||||
@@ -978,15 +1002,18 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
directly (simple commands only). Returns the launched job record."""
|
||||
log_path = TMUX_LOG_DIR / f"{session_id}.log"
|
||||
pid_path = TMUX_LOG_DIR / f"{session_id}.pid"
|
||||
pid_ready_path: Path | None = None
|
||||
bash = find_bash()
|
||||
if bash:
|
||||
# Run the existing bash wrapper verbatim through Git Bash, redirecting
|
||||
# all output to the log the poller reads. Paths handed to bash use
|
||||
# POSIX form + shell-quoting so drive paths / spaces survive.
|
||||
inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
|
||||
pp = shlex.quote(pid_path.as_posix())
|
||||
pid_ready_path = TMUX_LOG_DIR / f"{session_id}.pid.ready"
|
||||
pid_ready_path.unlink(missing_ok=True)
|
||||
inner.write_text(
|
||||
f"printf '%s\\n' \"$$\" > {pp}\n" + "\n".join(bash_lines) + "\n",
|
||||
_windows_local_pid_record_line(pid_path, pid_ready_path) + "\n"
|
||||
+ "\n".join(bash_lines) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
lp = shlex.quote(log_path.as_posix())
|
||||
@@ -1020,7 +1047,18 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
env=env,
|
||||
**detached_popen_kwargs(),
|
||||
)
|
||||
# Publish a valid Win32 ancestor first. The Git Bash runner may then
|
||||
# replace it with its own Win32 pid, but never before this fallback exists.
|
||||
pid_path.write_text(str(proc.pid), encoding="utf-8")
|
||||
if pid_ready_path is not None:
|
||||
try:
|
||||
pid_ready_path.touch()
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
"Could not publish Windows local PID handoff for %s: %s",
|
||||
session_id,
|
||||
e,
|
||||
)
|
||||
return {"pid": proc.pid, "log_path": str(log_path)}
|
||||
|
||||
@router.post("/api/model/download")
|
||||
|
||||
+99
-38
@@ -45,6 +45,7 @@ from src.upload_limits import read_upload_limited, EMAIL_COMPOSE_UPLOAD_MAX_BYTE
|
||||
|
||||
from routes.email_helpers import (
|
||||
_strip_think, _extract_reply, _apply_email_style_mechanics, require_owner, require_user, _assert_owns_account,
|
||||
_account_visible_to_owner,
|
||||
_q, _attach_compose_uploads, _cleanup_compose_uploads,
|
||||
_load_settings, _save_settings, _get_email_config,
|
||||
_send_smtp_message, _smtp_security_mode,
|
||||
@@ -195,6 +196,64 @@ def _coerce_port(value, default):
|
||||
return None, f"Invalid port {value!r}; must be a whole number"
|
||||
|
||||
|
||||
def _lock_email_account_owner_mutation(db, *owners: str) -> None:
|
||||
"""Delegate account/default serialization to the shared DB primitive."""
|
||||
from core.database import lock_email_account_owner_mutations
|
||||
|
||||
lock_email_account_owner_mutations(db, *owners)
|
||||
|
||||
|
||||
def _email_account_owner_scope(query, owner: str):
|
||||
"""Restrict a query to one normalized EmailAccount owner partition."""
|
||||
from core.database import EmailAccount
|
||||
from sqlalchemy import or_
|
||||
|
||||
if owner:
|
||||
return query.filter(EmailAccount.owner == owner)
|
||||
return query.filter(or_(EmailAccount.owner == None, EmailAccount.owner == "")) # noqa: E711
|
||||
|
||||
|
||||
def _discover_email_account_mutation_scope(account_id: str, owner: str) -> str:
|
||||
"""Read the initial lock key and fail closed before a mutation session."""
|
||||
from core.database import EmailAccount, SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.get(EmailAccount, account_id)
|
||||
if row is None or (owner and not _account_visible_to_owner(row, owner)):
|
||||
raise HTTPException(404, "Account not found")
|
||||
return row.owner or ""
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Account-owner mutation check failed: %s", exc)
|
||||
raise HTTPException(503, "Account check failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _lock_and_reload_email_account(db, account_id: str, owner: str, scope: str):
|
||||
"""Lock, reload, and revalidate an account, retrying if its owner moved."""
|
||||
from core.database import EmailAccount
|
||||
|
||||
owner_scopes = {scope or ""}
|
||||
while True:
|
||||
_lock_email_account_owner_mutation(db, *owner_scopes)
|
||||
row = db.get(EmailAccount, account_id, populate_existing=True)
|
||||
if row is None or (owner and not _account_visible_to_owner(row, owner)):
|
||||
raise HTTPException(404, "Account not found")
|
||||
|
||||
current_scope = row.owner or ""
|
||||
if current_scope in owner_scopes or db.get_bind().dialect.name == "sqlite":
|
||||
return row
|
||||
|
||||
# The account changed owner after discovery but before lock acquisition.
|
||||
# Release the partial lock set and reacquire all observed scopes in the
|
||||
# shared helper's canonical order, then validate from the database again.
|
||||
db.rollback()
|
||||
owner_scopes.add(current_scope)
|
||||
|
||||
|
||||
def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]:
|
||||
aliases = [owner or ""]
|
||||
try:
|
||||
@@ -4945,7 +5004,6 @@ def setup_email_routes():
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint,
|
||||
resolve_utility_fallback_candidates,
|
||||
resolve_chat_fallback_candidates,
|
||||
)
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
|
||||
@@ -5007,8 +5065,6 @@ def setup_email_routes():
|
||||
pass
|
||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
if not candidates:
|
||||
return {"success": False, "error": "No LLM endpoint configured"}
|
||||
|
||||
@@ -5268,13 +5324,11 @@ def setup_email_routes():
|
||||
# Build a candidate chain so a stale session-stored API key
|
||||
# (the most common cause of "authentication failed" here)
|
||||
# doesn't kill AI Reply outright — fall through to the
|
||||
# user's Utility / Default endpoints AND their configured
|
||||
# fallback chains. Dedupe by url+model so we don't retry
|
||||
# the same broken endpoint.
|
||||
# user's Utility / Default endpoints and active Utility fallback
|
||||
# chain. Dedupe by url+model so we don't retry the same endpoint.
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
from src.endpoint_resolver import (
|
||||
resolve_utility_fallback_candidates,
|
||||
resolve_chat_fallback_candidates,
|
||||
)
|
||||
_seen = set()
|
||||
_candidates = []
|
||||
@@ -5299,11 +5353,9 @@ def setup_email_routes():
|
||||
_add(_d_url, _d_model, _d_headers)
|
||||
except Exception:
|
||||
pass
|
||||
# Configured fallback chains last.
|
||||
# Active Utility fallbacks last.
|
||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
_messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_msg},
|
||||
@@ -5487,9 +5539,9 @@ def setup_email_routes():
|
||||
import uuid as _uuid
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_lock_email_account_owner_mutation(db, owner)
|
||||
q = db.query(EmailAccount).filter(EmailAccount.is_default == True) # noqa: E712
|
||||
if owner:
|
||||
q = q.filter(EmailAccount.owner == owner)
|
||||
q = _email_account_owner_scope(q, owner)
|
||||
row = q.first()
|
||||
if row is None:
|
||||
row = EmailAccount(id=_uuid.uuid4().hex, owner=owner, name="Default", is_default=True, enabled=True)
|
||||
@@ -5515,8 +5567,7 @@ def setup_email_routes():
|
||||
if data.get("smtp_password"):
|
||||
row.smtp_password = _enc(data["smtp_password"])
|
||||
clear_q = db.query(EmailAccount).filter(EmailAccount.id != row.id)
|
||||
if owner:
|
||||
clear_q = clear_q.filter(EmailAccount.owner == owner)
|
||||
clear_q = _email_account_owner_scope(clear_q, owner)
|
||||
clear_q.update({EmailAccount.is_default: False})
|
||||
db.commit()
|
||||
finally:
|
||||
@@ -5611,6 +5662,7 @@ def setup_email_routes():
|
||||
return {"ok": False, "error": port_err}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_lock_email_account_owner_mutation(db, owner)
|
||||
row = EmailAccount(
|
||||
id=_uuid.uuid4().hex,
|
||||
name=name,
|
||||
@@ -5637,9 +5689,7 @@ def setup_email_routes():
|
||||
# the one-default invariant — but scope it to THIS user's accounts,
|
||||
# otherwise creating a default would clear every other user's
|
||||
# default flag too.
|
||||
scope_q = db.query(EmailAccount)
|
||||
if owner:
|
||||
scope_q = scope_q.filter(EmailAccount.owner == owner)
|
||||
scope_q = _email_account_owner_scope(db.query(EmailAccount), owner)
|
||||
existing_count = scope_q.count()
|
||||
if row.is_default or existing_count == 0:
|
||||
scope_q.update({EmailAccount.is_default: False})
|
||||
@@ -5690,28 +5740,39 @@ def setup_email_routes():
|
||||
|
||||
@router.delete("/accounts/{account_id}")
|
||||
async def delete_email_account(account_id: str, owner: str = Depends(require_user)):
|
||||
_assert_owns_account(account_id, owner)
|
||||
initial_scope = _discover_email_account_mutation_scope(account_id, owner)
|
||||
from core.database import SessionLocal, EmailAccount
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.get(EmailAccount, account_id)
|
||||
if not row:
|
||||
return {"ok": False, "error": "Account not found"}
|
||||
row = _lock_and_reload_email_account(
|
||||
db, account_id, owner, initial_scope
|
||||
)
|
||||
row_scope = row.owner or ""
|
||||
was_default = bool(row.is_default)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
# Flush the removal before staging a replacement default. The
|
||||
# partial unique index is checked statement-by-statement, and the
|
||||
# ORM is otherwise free to UPDATE the promoted row before DELETE.
|
||||
db.flush()
|
||||
# If the deleted row was default, promote the next-oldest enabled
|
||||
# row owned by THIS user. Without the owner filter we'd promote
|
||||
# another user's account and the deleter would silently inherit
|
||||
# it as their default.
|
||||
if was_default:
|
||||
promote_q = db.query(EmailAccount).filter(EmailAccount.enabled == True) # noqa: E712
|
||||
if owner:
|
||||
promote_q = promote_q.filter(EmailAccount.owner == owner)
|
||||
promote = promote_q.order_by(EmailAccount.created_at.asc()).first()
|
||||
promote_q = db.query(EmailAccount).filter(
|
||||
EmailAccount.id != account_id,
|
||||
EmailAccount.enabled == True, # noqa: E712
|
||||
)
|
||||
promote_q = _email_account_owner_scope(promote_q, row_scope)
|
||||
promote = promote_q.order_by(
|
||||
EmailAccount.created_at.asc(), EmailAccount.id.asc()
|
||||
).first()
|
||||
if promote:
|
||||
promote.is_default = True
|
||||
db.commit()
|
||||
# Deletion and any replacement promotion are one durable state
|
||||
# transition, so another worker can never observe or race the old
|
||||
# split-commit gap.
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -5924,18 +5985,18 @@ def setup_email_routes():
|
||||
|
||||
@router.post("/accounts/{account_id}/set-default")
|
||||
async def set_default_account(account_id: str, owner: str = Depends(require_user)):
|
||||
_assert_owns_account(account_id, owner)
|
||||
initial_scope = _discover_email_account_mutation_scope(account_id, owner)
|
||||
from core.database import SessionLocal, EmailAccount
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.get(EmailAccount, account_id)
|
||||
if not row:
|
||||
return {"ok": False, "error": "Account not found"}
|
||||
# SECURITY: scope the "clear other defaults" sweep to this user's
|
||||
# accounts so we don't unset another user's default flag.
|
||||
clear_q = db.query(EmailAccount)
|
||||
if owner:
|
||||
clear_q = clear_q.filter(EmailAccount.owner == owner)
|
||||
row = _lock_and_reload_email_account(
|
||||
db, account_id, owner, initial_scope
|
||||
)
|
||||
# Scope the sweep to the target row's normalized owner partition;
|
||||
# this also handles visible legacy NULL/empty-owner accounts.
|
||||
clear_q = _email_account_owner_scope(
|
||||
db.query(EmailAccount), row.owner or ""
|
||||
)
|
||||
clear_q.update({EmailAccount.is_default: False})
|
||||
row.is_default = True
|
||||
db.commit()
|
||||
@@ -5954,7 +6015,7 @@ def setup_email_routes():
|
||||
raise HTTPException(400, "GOOGLE_OAUTH_CLIENT_ID not set — add it to .env")
|
||||
redirect_uri = (
|
||||
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
or f"{request.url.scheme}://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
)
|
||||
state = make_oauth_state(account_id, owner)
|
||||
params = urllib.parse.urlencode({
|
||||
@@ -5991,7 +6052,7 @@ def setup_email_routes():
|
||||
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||
redirect_uri = (
|
||||
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
or f"{request.url.scheme}://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
)
|
||||
import httpx as _httpx
|
||||
try:
|
||||
|
||||
@@ -127,6 +127,25 @@ def _load_grounding_backend():
|
||||
return cached
|
||||
|
||||
|
||||
def _model_input_to_device(value, device: str, torch):
|
||||
if not hasattr(value, "to"):
|
||||
return value
|
||||
if (
|
||||
device == "mps"
|
||||
and hasattr(torch, "float64")
|
||||
and getattr(value, "dtype", None) == torch.float64
|
||||
):
|
||||
return value.to(device=device, dtype=torch.float32)
|
||||
return value.to(device)
|
||||
|
||||
|
||||
def _model_inputs_to_device(inputs, device: str, torch) -> Dict[str, Any]:
|
||||
return {
|
||||
key: _model_input_to_device(value, device, torch)
|
||||
for key, value in inputs.items()
|
||||
}
|
||||
|
||||
|
||||
def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
|
||||
query = (text or "").strip()
|
||||
if not query:
|
||||
@@ -142,10 +161,7 @@ def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
|
||||
labels.append(f"a photo of {query}")
|
||||
try:
|
||||
inputs = processor(text=[labels], images=image, return_tensors="pt")
|
||||
model_inputs = {
|
||||
k: (v.to(device) if hasattr(v, "to") else v)
|
||||
for k, v in inputs.items()
|
||||
}
|
||||
model_inputs = _model_inputs_to_device(inputs, device, torch)
|
||||
with torch.no_grad():
|
||||
outputs = model(**model_inputs)
|
||||
target_sizes = torch.tensor([[image.height, image.width]])
|
||||
@@ -1869,10 +1885,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
try:
|
||||
inputs = processor(image, **kwargs)
|
||||
model_inputs = {
|
||||
k: (v.to(device) if hasattr(v, "to") else v)
|
||||
for k, v in inputs.items()
|
||||
}
|
||||
model_inputs = _model_inputs_to_device(inputs, device, torch)
|
||||
with torch.no_grad():
|
||||
outputs = model(**model_inputs)
|
||||
masks = processor.image_processor.post_process_masks(
|
||||
|
||||
+9
-33
@@ -46,10 +46,12 @@ _ENDPOINT_SETTING_FIELDS = {
|
||||
}
|
||||
|
||||
_ENDPOINT_FALLBACK_FIELDS = {
|
||||
"default_model_fallbacks": "Default Model Fallbacks",
|
||||
"foreground_model_fallbacks": "Foreground Model Fallbacks",
|
||||
"utility_model_fallbacks": "Utility Model Fallbacks",
|
||||
"vision_model_fallbacks": "Vision Model Fallbacks",
|
||||
}
|
||||
# `default_model_fallbacks` is intentionally absent. The legacy data remains
|
||||
# stored as-is even when an endpoint is removed, but no longer affects routing.
|
||||
|
||||
|
||||
def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list:
|
||||
@@ -179,7 +181,12 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
|
||||
if not isinstance(all_prefs, dict):
|
||||
return 0
|
||||
users = all_prefs.get("_users")
|
||||
pref_sets = users.values() if isinstance(users, dict) else [all_prefs]
|
||||
# A mixed store can contain auth-disabled foreground policy at the root
|
||||
# alongside named-owner preferences. Both are active namespaces; legacy
|
||||
# `default_model_fallbacks` remains untouched by the field allowlist.
|
||||
pref_sets = [all_prefs]
|
||||
if isinstance(users, dict):
|
||||
pref_sets.extend(users.values())
|
||||
cleared_users = 0
|
||||
for prefs in pref_sets:
|
||||
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
|
||||
@@ -2437,7 +2444,6 @@ def setup_model_routes(model_discovery):
|
||||
_user_prefs = _load_for_user(_user) or {}
|
||||
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
||||
model = (_user_prefs.get("default_model") or "").strip()
|
||||
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
||||
# If user has no personal default, fall back to global default
|
||||
# But only based on the "share_defaults_with_users" flag
|
||||
# (only if share_defaults_with_users is enabled)
|
||||
@@ -2446,12 +2452,9 @@ def setup_model_routes(model_discovery):
|
||||
ep_id = settings.get("default_endpoint_id", "")
|
||||
if not model:
|
||||
model = settings.get("default_model", "")
|
||||
if not _fallbacks:
|
||||
_fallbacks = settings.get("default_model_fallbacks") or []
|
||||
else:
|
||||
ep_id = settings.get("default_endpoint_id", "")
|
||||
model = settings.get("default_model", "")
|
||||
_fallbacks = settings.get("default_model_fallbacks") or []
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = None
|
||||
@@ -2466,33 +2469,6 @@ def setup_model_routes(model_discovery):
|
||||
if _user and not _is_admin:
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, _user)
|
||||
ep = ep_q.first()
|
||||
# Configured fallback chain — when the chosen default endpoint is
|
||||
# gone/disabled, honor the user's configured `default_model_fallbacks`
|
||||
# in order BEFORE arbitrarily grabbing the first enabled endpoint.
|
||||
# (Previously this jumped straight to "first enabled", which is why
|
||||
# deleting/changing the main endpoint silently reassigned the default
|
||||
# chat to some unrelated endpoint instead of the fallback.)
|
||||
if not ep:
|
||||
for entry in _fallbacks:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fid = (entry.get("endpoint_id") or "").strip()
|
||||
if not fid:
|
||||
continue
|
||||
cand_q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id == fid, ModelEndpoint.is_enabled == True
|
||||
)
|
||||
if _user and not _is_admin:
|
||||
cand_q = owner_filter(cand_q, ModelEndpoint, _user)
|
||||
cand = cand_q.first()
|
||||
if cand:
|
||||
ep = cand
|
||||
# Use the fallback entry's model. Reset even when empty
|
||||
# so we don't carry the prior endpoint's stale model onto
|
||||
# this fallback — the cached-models lookup below then
|
||||
# fills it from the fallback endpoint.
|
||||
model = (entry.get("model") or "").strip()
|
||||
break
|
||||
# Last resort: first enabled endpoint owned by THIS user. Do not
|
||||
# include null-owner/shared endpoints here: a brand-new user with
|
||||
# no explicit default should not auto-open a pending chat using an
|
||||
|
||||
+163
-92
@@ -1,11 +1,13 @@
|
||||
# routes/personal_routes.py
|
||||
"""Routes for personal documents management."""
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from src.request_models import DirectoryRequest
|
||||
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
||||
from src.rag_singleton import get_rag_manager
|
||||
@@ -18,7 +20,6 @@ UPLOADS_DIR = PERSONAL_UPLOADS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
|
||||
"""Return the per-owner upload directory used for direct RAG uploads."""
|
||||
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
|
||||
@@ -141,6 +142,22 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
"""
|
||||
router = APIRouter(prefix="/api/personal")
|
||||
|
||||
# Serializes directory index jobs across requests. Indexing runs in the
|
||||
# threadpool (#5558), so concurrent requests would otherwise run in parallel
|
||||
# and race PersonalDocsManager's unsynchronized list mutations and file
|
||||
# writes; before the threadpool move they serialized on the blocked event
|
||||
# loop, so one-at-a-time is behavior parity.
|
||||
#
|
||||
# An asyncio.Lock acquired in the async handler BEFORE offloading: a waiting
|
||||
# request parks on the event loop instead of pinning a threadpool worker (an
|
||||
# earlier threading.Lock taken INSIDE the worker meant queued jobs held pool
|
||||
# tokens while blocked, starving every other run_in_threadpool caller).
|
||||
# add/remove/reload all take this lock, so their mutations never interleave.
|
||||
# Per-router (not module-global) so each app binds it to its own event loop.
|
||||
# Scope is the single process: multi-worker deployments would need a shared
|
||||
# lock (out of scope for #5558).
|
||||
_index_job_lock = asyncio.Lock()
|
||||
|
||||
def _rag():
|
||||
"""Get the current RAG manager, retrying init if needed."""
|
||||
return get_rag_manager()
|
||||
@@ -172,8 +189,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
return {"files": files, "directories": directories}
|
||||
|
||||
@router.post("/reload")
|
||||
def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||
personal_docs_manager.refresh_index()
|
||||
async def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||
# refresh_index() re-extracts text across every tracked directory —
|
||||
# blocking work. Take the shared job lock (so it cannot race an add /
|
||||
# remove) and run it off the event loop.
|
||||
async with _index_job_lock:
|
||||
await run_in_threadpool(personal_docs_manager.refresh_index)
|
||||
return {"ok": True, "count": len(personal_docs_manager.index)}
|
||||
|
||||
@router.post("/add_directory")
|
||||
@@ -207,12 +228,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
# Use the RAGManager to index the directory
|
||||
rag = _rag()
|
||||
if rag:
|
||||
result = rag.index_personal_documents(directory, owner=owner)
|
||||
|
||||
def _index_directory():
|
||||
result = rag.index_personal_documents(directory, owner=owner)
|
||||
if result["success"]:
|
||||
# Also update the personal_docs_manager to track this
|
||||
# directory. Kept inside the offloaded call: it triggers
|
||||
# refresh_index(), which re-extracts text across tracked
|
||||
# directories.
|
||||
personal_docs_manager.add_directory(directory, index=False)
|
||||
return result
|
||||
|
||||
# Indexing walks, embeds, and stores the whole tree — minutes
|
||||
# on a real directory. The handler is async, so calling it
|
||||
# inline runs it on the event loop and every other request
|
||||
# queues behind it until it finishes (#5558). Serialize on the
|
||||
# async job lock BEFORE offloading so a queued request parks on
|
||||
# the loop instead of pinning a threadpool worker.
|
||||
async with _index_job_lock:
|
||||
result = await run_in_threadpool(_index_directory)
|
||||
|
||||
if result["success"]:
|
||||
# Also update the personal_docs_manager to track this directory
|
||||
personal_docs_manager.add_directory(directory, index=False)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
|
||||
@@ -251,17 +286,25 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
|
||||
logger.info(f"Removing directory from RAG: {directory}")
|
||||
|
||||
# Always remove from personal_docs_manager tracking
|
||||
if hasattr(personal_docs_manager, 'remove_directory'):
|
||||
personal_docs_manager.remove_directory(directory)
|
||||
|
||||
# Remove from RAG vector store (best-effort)
|
||||
rag = _rag()
|
||||
if rag:
|
||||
try:
|
||||
rag.remove_directory(directory)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
||||
|
||||
def _remove_directory():
|
||||
# Always remove from personal_docs_manager tracking. This
|
||||
# mutates the same unsynchronized list/index an add job touches
|
||||
# and re-extracts text (refresh_index), so it is blocking work.
|
||||
if hasattr(personal_docs_manager, 'remove_directory'):
|
||||
personal_docs_manager.remove_directory(directory)
|
||||
# Remove from RAG vector store (best-effort).
|
||||
if rag:
|
||||
try:
|
||||
rag.remove_directory(directory)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
||||
|
||||
# Same job lock as add/reload so remove cannot interleave with an
|
||||
# in-flight add; offloaded off the event loop.
|
||||
async with _index_job_lock:
|
||||
await run_in_threadpool(_remove_directory)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -289,54 +332,73 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
total_failed = 0
|
||||
uploaded_files = []
|
||||
|
||||
for upload in files:
|
||||
try:
|
||||
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
|
||||
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
||||
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
||||
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
||||
total_failed += 1
|
||||
continue
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
if ext == ".pdf":
|
||||
from src.personal_docs import extract_pdf_text
|
||||
text = extract_pdf_text(file_path)
|
||||
else:
|
||||
text = content_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if not text or not text.strip():
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
# Chunk and index
|
||||
chunks = rag._split_into_chunks(text, chunk_size=500)
|
||||
for i, chunk in enumerate(chunks):
|
||||
metadata = {
|
||||
"source": file_path,
|
||||
"filename": safe_name,
|
||||
"stored_filename": stored_name,
|
||||
"directory": upload_dir,
|
||||
"type": ext,
|
||||
"chunk_id": i,
|
||||
}
|
||||
if user:
|
||||
metadata["owner"] = user
|
||||
if rag.add_document(chunk, metadata):
|
||||
total_indexed += 1
|
||||
else:
|
||||
# Chunking, embedding and the tracking update are blocking work over the
|
||||
# same vector/tracking state add_directory mutates (#5634). Take the
|
||||
# shared job lock BEFORE offloading so a queued request parks on the loop
|
||||
# instead of pinning a threadpool worker, matching add_directory.
|
||||
# Read and process one capped payload at a time so a multi-file request
|
||||
# cannot retain len(files) * PERSONAL_UPLOAD_MAX_BYTES in memory.
|
||||
async with _index_job_lock:
|
||||
for upload in files:
|
||||
try:
|
||||
file_path, stored_name, safe_name = _unique_personal_upload_path(
|
||||
upload_dir, upload.filename
|
||||
)
|
||||
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
||||
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
||||
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
uploaded_files.append(safe_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
||||
total_failed += 1
|
||||
def _index_upload():
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
# Track uploads directory
|
||||
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
||||
personal_docs_manager.add_directory(upload_dir, index=False)
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
if ext == ".pdf":
|
||||
from src.personal_docs import extract_pdf_text
|
||||
text = extract_pdf_text(file_path)
|
||||
else:
|
||||
text = content_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if not text or not text.strip():
|
||||
return 0, 1, None
|
||||
|
||||
indexed = 0
|
||||
failed = 0
|
||||
chunks = rag._split_into_chunks(text, chunk_size=500)
|
||||
for i, chunk in enumerate(chunks):
|
||||
metadata = {
|
||||
"source": file_path,
|
||||
"filename": safe_name,
|
||||
"stored_filename": stored_name,
|
||||
"directory": upload_dir,
|
||||
"type": ext,
|
||||
"chunk_id": i,
|
||||
}
|
||||
if user:
|
||||
metadata["owner"] = user
|
||||
if rag.add_document(chunk, metadata):
|
||||
indexed += 1
|
||||
else:
|
||||
failed += 1
|
||||
return indexed, failed, safe_name
|
||||
|
||||
indexed, failed, uploaded_name = await run_in_threadpool(_index_upload)
|
||||
total_indexed += indexed
|
||||
total_failed += failed
|
||||
if uploaded_name:
|
||||
uploaded_files.append(uploaded_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
||||
total_failed += 1
|
||||
|
||||
# Same transition, same lock: the tracking update must not land
|
||||
# while another job is mid-write over the same state.
|
||||
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
||||
await run_in_threadpool(
|
||||
personal_docs_manager.add_directory, upload_dir, index=False
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -349,38 +411,47 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||
"""Delete a specific file from RAG index and optionally from disk."""
|
||||
try:
|
||||
# Remove chunks from RAG vector store (best-effort)
|
||||
removed = 0
|
||||
rag = _rag()
|
||||
if rag:
|
||||
try:
|
||||
removed = rag.delete_by_source(filepath)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
||||
def _delete_file():
|
||||
# Remove chunks from RAG vector store (best-effort)
|
||||
removed = 0
|
||||
rag = _rag()
|
||||
if rag:
|
||||
try:
|
||||
removed = rag.delete_by_source(filepath)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
||||
|
||||
# Delete file from disk if it's in the caller's own uploads dir.
|
||||
# Scope to the per-owner subdir, not the shared uploads root, so one
|
||||
# admin can't delete another user's personal files by path.
|
||||
deleted_from_disk = False
|
||||
try:
|
||||
abs_target = os.path.realpath(filepath)
|
||||
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
||||
in_uploads = (
|
||||
abs_target == base_abs
|
||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||
)
|
||||
except ValueError:
|
||||
# commonpath raises on mixed drives / non-comparable paths
|
||||
in_uploads = False
|
||||
if in_uploads and abs_target != base_abs:
|
||||
# Delete file from disk if it's in the caller's own uploads dir.
|
||||
# Scope to the per-owner subdir, not the shared uploads root, so one
|
||||
# admin can't delete another user's personal files by path.
|
||||
deleted_from_disk = False
|
||||
try:
|
||||
os.remove(abs_target)
|
||||
deleted_from_disk = True
|
||||
except FileNotFoundError:
|
||||
pass # already gone — race with another request or cleanup
|
||||
abs_target = os.path.realpath(filepath)
|
||||
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
||||
in_uploads = (
|
||||
abs_target == base_abs
|
||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||
)
|
||||
except ValueError:
|
||||
# commonpath raises on mixed drives / non-comparable paths
|
||||
in_uploads = False
|
||||
if in_uploads and abs_target != base_abs:
|
||||
try:
|
||||
os.remove(abs_target)
|
||||
deleted_from_disk = True
|
||||
except FileNotFoundError:
|
||||
pass # already gone — race with another request or cleanup
|
||||
|
||||
# Exclude the file from the listing (persists across restarts)
|
||||
personal_docs_manager.exclude_file(filepath)
|
||||
# Exclude the file from the listing (persists across restarts)
|
||||
personal_docs_manager.exclude_file(filepath)
|
||||
return removed, deleted_from_disk
|
||||
|
||||
# Vector removal, the disk unlink and the exclusion write are one
|
||||
# transition over the same state add_directory mutates (#5634), and
|
||||
# all three block. Take the shared job lock BEFORE offloading, as
|
||||
# add_directory does.
|
||||
async with _index_job_lock:
|
||||
removed, deleted_from_disk = await run_in_threadpool(_delete_file)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
+51
-11
@@ -7,6 +7,10 @@ from src.auth_helpers import get_current_user
|
||||
from src.constants import USER_PREFS_FILE
|
||||
|
||||
PREFS_FILE = USER_PREFS_FILE
|
||||
_FOREGROUND_POLICY_KEYS = (
|
||||
"foreground_fallback_enabled",
|
||||
"foreground_model_fallbacks",
|
||||
)
|
||||
|
||||
|
||||
def _load():
|
||||
@@ -26,14 +30,27 @@ def _save(prefs):
|
||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||
"""Load preferences for a specific user."""
|
||||
all_prefs = _load()
|
||||
if "_users" in all_prefs:
|
||||
users = all_prefs.get("_users")
|
||||
if isinstance(users, dict):
|
||||
if user is None:
|
||||
# Auth disabled — return first user's prefs for backward compat
|
||||
users = all_prefs["_users"]
|
||||
return dict(next(iter(users.values()), {}))
|
||||
return dict(all_prefs["_users"].get(user, {}))
|
||||
# Legacy flat format — return as-is
|
||||
return dict(all_prefs)
|
||||
prefs = dict(next(iter(users.values()), {}))
|
||||
# Foreground fallback consent is never borrowed from a named
|
||||
# owner. Auth-disabled operation has a separate flat/root opt-in
|
||||
# that remains inert when authentication is enabled again.
|
||||
for key in _FOREGROUND_POLICY_KEYS:
|
||||
prefs.pop(key, None)
|
||||
if key in all_prefs:
|
||||
prefs[key] = all_prefs[key]
|
||||
return prefs
|
||||
prefs = users.get(user, {})
|
||||
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||
# A legacy flat store belongs only to auth-disabled single-user mode.
|
||||
# Copying it into the first named user's new `_users` record during an
|
||||
# auth transition would silently transfer another user's preferences and,
|
||||
# critically, foreground fallback consent. Named owners therefore start
|
||||
# with an empty record and must write their own preferences explicitly.
|
||||
return dict(all_prefs) if user is None else {}
|
||||
|
||||
|
||||
def _save_for_user(user: Optional[str], prefs: dict):
|
||||
@@ -45,17 +62,40 @@ def _save_for_user(user: Optional[str], prefs: dict):
|
||||
# `prefs` flat would overwrite the whole `_users` map and destroy every
|
||||
# other user's preferences. Instead write back into the same (first)
|
||||
# slot _load_for_user(None) reads from, preserving the others.
|
||||
if "_users" in all_prefs:
|
||||
users = all_prefs["_users"]
|
||||
users = all_prefs.get("_users")
|
||||
if isinstance(users, dict):
|
||||
first_key = next(iter(users), None)
|
||||
if first_key is not None:
|
||||
users[first_key] = prefs
|
||||
existing_named = users.get(first_key)
|
||||
existing_named = (
|
||||
dict(existing_named)
|
||||
if isinstance(existing_named, dict)
|
||||
else {}
|
||||
)
|
||||
named_foreground = {
|
||||
key: existing_named[key]
|
||||
for key in _FOREGROUND_POLICY_KEYS
|
||||
if key in existing_named
|
||||
}
|
||||
users[first_key] = {
|
||||
key: value
|
||||
for key, value in prefs.items()
|
||||
if key not in _FOREGROUND_POLICY_KEYS
|
||||
}
|
||||
users[first_key].update(named_foreground)
|
||||
for key in _FOREGROUND_POLICY_KEYS:
|
||||
if key in prefs:
|
||||
all_prefs[key] = prefs[key]
|
||||
_save(all_prefs)
|
||||
return
|
||||
_save(prefs)
|
||||
return
|
||||
if "_users" not in all_prefs:
|
||||
all_prefs = {"_users": {}}
|
||||
if not isinstance(all_prefs.get("_users"), dict):
|
||||
# Preserve the flat single-user object as inert legacy data while
|
||||
# creating the first named-owner namespace. In particular, historical
|
||||
# fallback values must not be deleted or copied into the new owner.
|
||||
all_prefs = dict(all_prefs)
|
||||
all_prefs["_users"] = {}
|
||||
all_prefs["_users"][user] = prefs
|
||||
_save(all_prefs)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create/remove the switchable, non-default 'Demo' EmailAccount in Odysseus.
|
||||
"""Create/remove the switchable 'Demo' EmailAccount in Odysseus.
|
||||
|
||||
Mirrors the existing local-Dovecot account (localhost:31143, STARTTLS) but points
|
||||
at the throwaway demo@odysseus.local mailbox. Password is stored Fernet-encrypted
|
||||
@@ -20,7 +20,14 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from core.database import SessionLocal, EmailAccount, Base, engine # noqa: E402
|
||||
from core.database import ( # noqa: E402
|
||||
Base,
|
||||
EmailAccount,
|
||||
SessionLocal,
|
||||
engine,
|
||||
lock_email_account_owner_mutations,
|
||||
)
|
||||
from sqlalchemy import or_ # noqa: E402
|
||||
from src.secret_storage import encrypt # noqa: E402
|
||||
|
||||
NAME = "Demo"
|
||||
@@ -31,18 +38,98 @@ IMAP_PASSWORD = "demodemo"
|
||||
OWNER = ""
|
||||
|
||||
|
||||
def setup() -> int:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
def _owner_scope(query, owner: str):
|
||||
if owner:
|
||||
return query.filter(EmailAccount.owner == owner)
|
||||
return query.filter(or_(EmailAccount.owner == None, EmailAccount.owner == "")) # noqa: E711
|
||||
|
||||
|
||||
def _discover_demo_scopes() -> set[str]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
acct = db.query(EmailAccount).filter(
|
||||
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
|
||||
).first()
|
||||
return {
|
||||
row.owner or ""
|
||||
for row in db.query(EmailAccount).filter(
|
||||
EmailAccount.name == NAME,
|
||||
EmailAccount.imap_user == IMAP_USER,
|
||||
).all()
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _lock_and_load_demo_rows(db, scopes: set[str]):
|
||||
"""Reload Demo rows under every observed owner lock."""
|
||||
scopes = set(scopes) or {OWNER}
|
||||
while True:
|
||||
lock_email_account_owner_mutations(db, *scopes)
|
||||
rows = (
|
||||
db.query(EmailAccount)
|
||||
.filter(
|
||||
EmailAccount.name == NAME,
|
||||
EmailAccount.imap_user == IMAP_USER,
|
||||
)
|
||||
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
||||
.all()
|
||||
)
|
||||
current_scopes = {row.owner or "" for row in rows}
|
||||
if current_scopes.issubset(scopes) or db.get_bind().dialect.name == "sqlite":
|
||||
return rows
|
||||
db.rollback()
|
||||
scopes.update(current_scopes)
|
||||
|
||||
|
||||
def _promote_oldest_enabled(db, owner: str, excluded_ids: list[str]) -> None:
|
||||
remaining = _owner_scope(
|
||||
db.query(EmailAccount).filter(
|
||||
EmailAccount.enabled == True, # noqa: E712
|
||||
~EmailAccount.id.in_(excluded_ids),
|
||||
),
|
||||
owner,
|
||||
)
|
||||
if remaining.filter(EmailAccount.is_default == True).first() is not None: # noqa: E712
|
||||
return
|
||||
promote = remaining.order_by(
|
||||
EmailAccount.created_at.asc(), EmailAccount.id.asc()
|
||||
).first()
|
||||
if promote is not None:
|
||||
promote.is_default = True
|
||||
|
||||
|
||||
def setup() -> int:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
scopes = _discover_demo_scopes() | {OWNER}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = _lock_and_load_demo_rows(db, scopes)
|
||||
acct = rows[0] if rows else None
|
||||
if acct is None:
|
||||
acct = EmailAccount(id=uuid.uuid4().hex, name=NAME)
|
||||
db.add(acct)
|
||||
old_scope = acct.owner or ""
|
||||
was_default = bool(acct.is_default)
|
||||
if old_scope != OWNER:
|
||||
# Move a non-default row first so the unique index cannot see two
|
||||
# defaults transiently while SQLAlchemy flushes the owner move and
|
||||
# old-scope promotion in separate UPDATE statements.
|
||||
acct.is_default = False
|
||||
acct.owner = OWNER
|
||||
db.flush()
|
||||
if was_default:
|
||||
_promote_oldest_enabled(db, old_scope, [acct.id])
|
||||
|
||||
target_default = _owner_scope(
|
||||
db.query(EmailAccount).filter(
|
||||
EmailAccount.id != acct.id,
|
||||
EmailAccount.is_default == True, # noqa: E712
|
||||
),
|
||||
OWNER,
|
||||
).first()
|
||||
acct.owner = OWNER
|
||||
acct.is_default = False # never default — user switches to it
|
||||
# Keep Demo non-default when a real default exists. If it is the only
|
||||
# enabled account, it must be default to preserve normal create
|
||||
# semantics and avoid leaving the owner partition without one.
|
||||
acct.is_default = target_default is None
|
||||
acct.enabled = True
|
||||
acct.imap_host = "localhost"
|
||||
acct.imap_port = 31143
|
||||
@@ -57,20 +144,27 @@ def setup() -> int:
|
||||
acct.smtp_password = encrypt(IMAP_PASSWORD)
|
||||
acct.from_address = IMAP_USER
|
||||
db.commit()
|
||||
print(f"'{NAME}' account ready (id={acct.id}, non-default, switchable).")
|
||||
state = "default" if acct.is_default else "non-default"
|
||||
print(f"'{NAME}' account ready (id={acct.id}, {state}, switchable).")
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def teardown() -> int:
|
||||
scopes = _discover_demo_scopes()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.query(EmailAccount).filter(
|
||||
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
|
||||
).all()
|
||||
rows = _lock_and_load_demo_rows(db, scopes)
|
||||
deleted_ids = [row.id for row in rows]
|
||||
default_scopes = {row.owner or "" for row in rows if row.is_default}
|
||||
for r in rows:
|
||||
db.delete(r)
|
||||
# Ensure the old default DELETE reaches the database before a
|
||||
# replacement UPDATE; the unique index is enforced per statement.
|
||||
db.flush()
|
||||
for owner in default_scopes:
|
||||
_promote_oldest_enabled(db, owner, deleted_ids)
|
||||
db.commit()
|
||||
print(f"removed {len(rows)} '{NAME}' account row(s).")
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Make retained SearXNG settings inherit defaults without replacing them."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from yaml.nodes import MappingNode
|
||||
from yaml.tokens import BlockMappingStartToken, FlowMappingStartToken
|
||||
|
||||
|
||||
_UTF8_BOM = b"\xef\xbb\xbf"
|
||||
|
||||
|
||||
def _parse_root_mapping(text: str) -> tuple[MappingNode | None, dict]:
|
||||
"""Parse settings with the same safe YAML semantics SearXNG uses."""
|
||||
try:
|
||||
loaded = yaml.safe_load(text)
|
||||
node = yaml.compose(text, Loader=yaml.SafeLoader)
|
||||
except yaml.YAMLError:
|
||||
raise ValueError("settings file is not valid single-document YAML") from None
|
||||
|
||||
if loaded is None and node is None:
|
||||
return None, {}
|
||||
if not isinstance(loaded, dict) or not isinstance(node, MappingNode):
|
||||
raise ValueError("settings root is not a mapping")
|
||||
return node, loaded
|
||||
|
||||
|
||||
def _flow_mapping_start(text: str) -> int:
|
||||
"""Return the root flow mapping's opening-brace character offset."""
|
||||
try:
|
||||
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
||||
if isinstance(token, FlowMappingStartToken):
|
||||
return token.start_mark.index
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
raise ValueError("flow-style settings mapping has no opening brace")
|
||||
|
||||
|
||||
def _newline_for(contents: bytes) -> bytes:
|
||||
first_lf = contents.find(b"\n")
|
||||
if first_lf > 0 and contents[first_lf - 1 : first_lf + 1] == b"\r\n":
|
||||
return b"\r\n"
|
||||
return b"\n"
|
||||
|
||||
|
||||
def _block_mapping_position(text: str, root: MappingNode | None) -> tuple[int, int]:
|
||||
"""Return a safe character offset and indent for a root block mapping key."""
|
||||
if root is None:
|
||||
return len(text), 0
|
||||
|
||||
try:
|
||||
for token in yaml.scan(text, Loader=yaml.SafeLoader):
|
||||
if not isinstance(token, BlockMappingStartToken):
|
||||
continue
|
||||
line_start = token.start_mark.index - token.start_mark.column
|
||||
if not text[line_start : token.start_mark.index].strip():
|
||||
return line_start, token.start_mark.column
|
||||
return root.end_mark.index, token.start_mark.column
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
return root.end_mark.index, root.start_mark.column
|
||||
|
||||
|
||||
def _add_block_default_inheritance(
|
||||
contents: bytes, text: str, root: MappingNode | None
|
||||
) -> bytes:
|
||||
newline = _newline_for(contents)
|
||||
character_offset, indent_width = _block_mapping_position(text, root)
|
||||
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
|
||||
offset = bom_length + len(text[:character_offset].encode("utf-8"))
|
||||
separator = b""
|
||||
if offset not in (0, bom_length) and not contents[:offset].endswith((b"\n", b"\r")):
|
||||
separator = newline
|
||||
addition = (
|
||||
separator
|
||||
+ b" " * indent_width
|
||||
+ b"use_default_settings: true"
|
||||
+ newline
|
||||
)
|
||||
return contents[:offset] + addition + contents[offset:]
|
||||
|
||||
|
||||
def migrate_settings(path: Path) -> bool:
|
||||
"""Add the missing inheritance key atomically; return whether the file changed."""
|
||||
source_stat = path.lstat()
|
||||
if not stat.S_ISREG(source_stat.st_mode):
|
||||
raise ValueError(f"settings path is not a regular file: {path}")
|
||||
|
||||
contents = path.read_bytes()
|
||||
if not contents:
|
||||
return False
|
||||
|
||||
text = contents.decode("utf-8-sig")
|
||||
root, loaded = _parse_root_mapping(text)
|
||||
if "use_default_settings" in loaded:
|
||||
return False
|
||||
|
||||
if root is not None and root.flow_style:
|
||||
start = _flow_mapping_start(text)
|
||||
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
|
||||
offset = bom_length + len(text[: start + 1].encode("utf-8"))
|
||||
separator = b", " if root.value else b""
|
||||
updated = (
|
||||
contents[:offset]
|
||||
+ b"use_default_settings: true"
|
||||
+ separator
|
||||
+ contents[offset:]
|
||||
)
|
||||
else:
|
||||
updated = _add_block_default_inheritance(contents, text, root)
|
||||
fd, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.odysseus-", dir=path.parent
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
# chmod before chown: the Compose cap set is `cap_drop: ALL` plus
|
||||
# CHOWN/SETGID/SETUID/DAC_OVERRIDE, with no FOWNER. Once the temporary
|
||||
# file belongs to searxng:searxng — which every retained settings file
|
||||
# does, because searxng's entrypoint chowns /etc/searxng — root can no
|
||||
# longer chmod it and the migration dies with EPERM.
|
||||
os.fchmod(fd, stat.S_IMODE(source_stat.st_mode))
|
||||
os.fchown(fd, source_stat.st_uid, source_stat.st_gid)
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
fd = -1
|
||||
handle.write(updated)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
if fd >= 0:
|
||||
os.close(fd)
|
||||
temporary.unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) > 2:
|
||||
print(f"usage: {Path(argv[0]).name} [settings.yml]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
path = Path(argv[1]) if len(argv) == 2 else Path("/etc/searxng/settings.yml")
|
||||
try:
|
||||
changed = migrate_settings(path)
|
||||
except (OSError, UnicodeError, ValueError) as exc:
|
||||
print(f"SearXNG settings migration failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if changed:
|
||||
print("Added use_default_settings inheritance to retained SearXNG settings")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -100,6 +100,18 @@ def _parse_scalar(raw: str) -> Any:
|
||||
if raw.lower() in ("null", "none", "~"):
|
||||
return None
|
||||
if (raw[0] == raw[-1]) and raw[0] in ("'", '"'):
|
||||
if raw[0] == '"':
|
||||
# _emit_scalar writes double-quoted scalars with json.dumps, so
|
||||
# decode the escapes instead of only stripping the quotes. Without
|
||||
# this, `\"` / `\\` / `\uXXXX` stayed verbatim in the value and the
|
||||
# next save escaped their backslashes again, doubling them on every
|
||||
# load/save cycle (issue #5210).
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
# Hand-written file using escapes JSON rejects (e.g. a bare
|
||||
# Windows path). Keep the previous literal reading.
|
||||
pass
|
||||
return raw[1:-1]
|
||||
# Try number
|
||||
try:
|
||||
@@ -171,6 +183,26 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
return fm, body
|
||||
|
||||
|
||||
# Characters that force a quoted scalar. The punctuation would otherwise change
|
||||
# how the value reads back; the second row is every character str.splitlines()
|
||||
# treats as a line break, and parse_frontmatter() reads one scalar per line, so
|
||||
# emitting one of those bare would split the value across lines.
|
||||
_FM_MUST_QUOTE = (
|
||||
":", "#", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@",
|
||||
"\n", "\r", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029",
|
||||
)
|
||||
|
||||
# json.dumps escapes every C0 control character, but with ensure_ascii=False it
|
||||
# passes NEL / LINE SEPARATOR / PARAGRAPH SEPARATOR through literally, and
|
||||
# str.splitlines() still breaks on all three. Re-escape exactly those, which
|
||||
# json.loads decodes again on the way in, so the pair stays symmetric.
|
||||
_FM_POST_DUMPS_ESCAPES = (
|
||||
("\x85", "\\u0085"),
|
||||
("\u2028", "\\u2028"),
|
||||
("\u2029", "\\u2029"),
|
||||
)
|
||||
|
||||
|
||||
def _emit_scalar(v: Any) -> str:
|
||||
if v is None:
|
||||
return "null"
|
||||
@@ -181,8 +213,15 @@ def _emit_scalar(v: Any) -> str:
|
||||
if isinstance(v, list):
|
||||
return "[" + ", ".join(_emit_scalar(x) for x in v) + "]"
|
||||
s = str(v)
|
||||
if any(c in s for c in (":", "#", "\n", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@")):
|
||||
return json.dumps(s)
|
||||
if any(c in s for c in _FM_MUST_QUOTE):
|
||||
# ensure_ascii=False keeps non-ASCII text as itself. SKILL.md is UTF-8 at
|
||||
# both ends (skills.py reads it, atomic_write_text writes it), so the
|
||||
# \uXXXX form bought nothing and leaked into the parsed value (#5210).
|
||||
out = json.dumps(s, ensure_ascii=False)
|
||||
for ch, esc in _FM_POST_DUMPS_ESCAPES:
|
||||
if ch in out:
|
||||
out = out.replace(ch, esc)
|
||||
return out
|
||||
return s
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, Iterable, List, Optional, Tuple, cast
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
|
||||
from src.url_safety import check_outbound_url
|
||||
from src.url_safety import _default_resolver, check_outbound_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +27,7 @@ TEXT_NAMES = {"skill.md", "license", "license.md", "readme.md"}
|
||||
_GITHUB_HOSTS = frozenset({
|
||||
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
|
||||
})
|
||||
_SKILLS_SH_HOSTS = frozenset({"skills.sh", "www.skills.sh"})
|
||||
|
||||
|
||||
def _github_host(url: str) -> str:
|
||||
@@ -72,18 +75,158 @@ def _is_text_file(name: str) -> bool:
|
||||
_MAX_FETCH_REDIRECTS = 5
|
||||
|
||||
|
||||
def _check_fetch_url(url: str) -> None:
|
||||
"""SSRF guard for skill-import fetches (defense-in-depth).
|
||||
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
|
||||
"""Parse and de-duplicate one resolver snapshot in resolver order."""
|
||||
ips: List[ipaddress._BaseAddress] = []
|
||||
seen = set()
|
||||
for raw in raw_ips:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
try:
|
||||
ip = ipaddress.ip_address(raw.split("%", 1)[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if ip in seen:
|
||||
continue
|
||||
seen.add(ip)
|
||||
ips.append(ip)
|
||||
return ips
|
||||
|
||||
Skill bundles only ever come from public GitHub, never an internal
|
||||
address, so block private/loopback/link-local targets on every hop —
|
||||
matching the hardened web-fetch path in
|
||||
``services/search/content.py:_get_public_url`` rather than the lenient
|
||||
default used for admin-configured model endpoints.
|
||||
"""
|
||||
ok, reason = check_outbound_url(url, block_private=True)
|
||||
|
||||
def _resolve_and_check_url(url: str) -> List[ipaddress._BaseAddress]:
|
||||
"""Return the exact address snapshot approved for one fetch hop."""
|
||||
resolved_ips: List[str] = []
|
||||
|
||||
def _recording_resolver(host: str) -> List[str]:
|
||||
answers = list(_default_resolver(host))
|
||||
resolved_ips[:] = answers
|
||||
return answers
|
||||
|
||||
ok, reason = check_outbound_url(
|
||||
url,
|
||||
block_private=True,
|
||||
resolver=_recording_resolver,
|
||||
)
|
||||
if not ok:
|
||||
raise SkillImportError(reason)
|
||||
raise SkillImportError(f"outbound URL blocked: {reason}")
|
||||
|
||||
pinned_ips = _validated_ips(resolved_ips)
|
||||
if not pinned_ips:
|
||||
raise SkillImportError("outbound URL blocked: host did not resolve to a usable address")
|
||||
return pinned_ips
|
||||
|
||||
|
||||
# Backward compatibility alias for tests importing _check_fetch_url directly
|
||||
_check_fetch_url = _resolve_and_check_url
|
||||
|
||||
|
||||
class _PinnedBackend(httpcore.NetworkBackend):
|
||||
"""Connect only to addresses from one validated DNS snapshot."""
|
||||
|
||||
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||
self._ips = [str(ip) for ip in ips]
|
||||
self._real = httpcore.SyncBackend()
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options=None,
|
||||
):
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
last_exc: Optional[Exception] = None
|
||||
for ip in self._ips:
|
||||
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
||||
try:
|
||||
return self._real.connect_tcp(
|
||||
ip,
|
||||
port,
|
||||
remaining,
|
||||
local_address,
|
||||
socket_options,
|
||||
)
|
||||
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
|
||||
last_exc = exc
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
break
|
||||
if last_exc is not None:
|
||||
raise last_exc
|
||||
raise httpcore.ConnectError("no validated address available")
|
||||
|
||||
def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
return self._real.connect_unix_socket(path, timeout, socket_options)
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
return self._real.sleep(seconds)
|
||||
|
||||
|
||||
_HTTPCORE_TO_HTTPX_EXC = {
|
||||
httpcore.ConnectError: httpx.ConnectError,
|
||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||
httpcore.LocalProtocolError: httpx.LocalProtocolError,
|
||||
httpcore.NetworkError: httpx.NetworkError,
|
||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||
httpcore.ProtocolError: httpx.ProtocolError,
|
||||
httpcore.ProxyError: httpx.ProxyError,
|
||||
httpcore.ReadError: httpx.ReadError,
|
||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||
httpcore.TimeoutException: httpx.TimeoutException,
|
||||
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
|
||||
httpcore.WriteError: httpx.WriteError,
|
||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||
}
|
||||
|
||||
|
||||
class _PinnedTransport(httpx.BaseTransport):
|
||||
"""Pin socket connects while preserving URL authority, Host, and TLS SNI."""
|
||||
|
||||
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||
self._pinned_ips = list(ips)
|
||||
self._pool = httpcore.ConnectionPool(
|
||||
ssl_context=httpx.create_ssl_context(),
|
||||
http1=True,
|
||||
http2=False,
|
||||
network_backend=_PinnedBackend(ips),
|
||||
)
|
||||
|
||||
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
||||
core_request = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
core_response = None
|
||||
try:
|
||||
core_response = self._pool.handle_request(core_request)
|
||||
content = b"".join(cast(Iterable[bytes], core_response.stream))
|
||||
except Exception as exc:
|
||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||
if mapped is not None:
|
||||
raise mapped(str(exc)) from exc
|
||||
raise
|
||||
finally:
|
||||
if core_response is not None:
|
||||
core_response.close()
|
||||
|
||||
return httpx.Response(
|
||||
status_code=core_response.status,
|
||||
headers=core_response.headers,
|
||||
content=content,
|
||||
extensions=core_response.extensions,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
|
||||
def _get_checked(
|
||||
@@ -100,49 +243,76 @@ def _get_checked(
|
||||
hand lets us re-validate every hop, closing that blind-SSRF gap.
|
||||
"""
|
||||
current = url
|
||||
with httpx.Client(follow_redirects=False, timeout=timeout) as client:
|
||||
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||
_check_fetch_url(current)
|
||||
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||
pinned_ips = _resolve_and_check_url(current)
|
||||
with httpx.Client(
|
||||
transport=_PinnedTransport(pinned_ips),
|
||||
follow_redirects=False,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
r = client.get(current, headers=headers)
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
location = r.headers.get("location")
|
||||
if not location:
|
||||
return r
|
||||
current = urljoin(str(r.url), location)
|
||||
continue
|
||||
return r
|
||||
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
location = r.headers.get("location")
|
||||
if not location:
|
||||
return r
|
||||
current = urljoin(str(r.url), location)
|
||||
continue
|
||||
return r
|
||||
raise SkillImportError("too many redirects while fetching skill bundle")
|
||||
|
||||
|
||||
def parse_skill_source(url: str) -> ResolvedSource:
|
||||
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
|
||||
raw = (url or "").strip()
|
||||
if not raw:
|
||||
url = (url or "").strip()
|
||||
if not url:
|
||||
raise SkillImportError("URL is required")
|
||||
|
||||
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
|
||||
if "skills.sh" in raw and "github.com" not in raw:
|
||||
r = _get_checked(raw, timeout=20.0)
|
||||
# ``urlparse`` only reports an unambiguous scheme when the URL carries the
|
||||
# ``scheme://`` form. Opaque schemes (``mailto:``, ``javascript:``) and a
|
||||
# schemeless ``host:port`` both parse a "scheme" that is not one, so they
|
||||
# fall through to the host check below and are rejected on the host instead.
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
if scheme not in ("http", "https"):
|
||||
if scheme and url.lower().startswith(f"{scheme}://"):
|
||||
raise SkillImportError(f"unsupported URL scheme: {scheme}")
|
||||
# Schemeless "github.com/owner/repo" — accept only a supported host.
|
||||
rough_host = (urlparse("//" + url).hostname or "").lower()
|
||||
if rough_host not in _GITHUB_HOSTS and rough_host not in _SKILLS_SH_HOSTS:
|
||||
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
|
||||
url = "https://" + url
|
||||
|
||||
parsed = urlparse(url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if hostname not in _GITHUB_HOSTS and hostname not in _SKILLS_SH_HOSTS:
|
||||
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
|
||||
|
||||
# A skills.sh link is only usable if it redirects to an exact supported
|
||||
# GitHub host. Scraping the page body for a github.com link cannot work:
|
||||
# skill pages only ever link the repository root, never the skill's
|
||||
# subdirectory, so the scrape resolves every skill in a repo to the same
|
||||
# (wrong) bundle. Fail with an actionable message instead.
|
||||
if hostname in _SKILLS_SH_HOSTS:
|
||||
r = _get_checked(url, timeout=20.0)
|
||||
if r.status_code >= 400:
|
||||
raise _github_response_error(r)
|
||||
final = str(r.url)
|
||||
_assert_github_url(final, context="redirect target")
|
||||
# Page may embed a github link; prefer final URL if redirected.
|
||||
if "github.com" in final:
|
||||
raw = final
|
||||
else:
|
||||
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
|
||||
if m:
|
||||
raw = m.group(0).rstrip(".,)")
|
||||
if _github_host(final) not in _GITHUB_HOSTS:
|
||||
raise SkillImportError(
|
||||
"skills.sh did not redirect to GitHub — open the skill's "
|
||||
"repository on GitHub, navigate to the exact skill folder or "
|
||||
"SKILL.md file, and paste that URL; the repository-root link "
|
||||
"alone is not sufficient"
|
||||
)
|
||||
url = final
|
||||
|
||||
parsed = urlparse(raw)
|
||||
host = _github_host(raw)
|
||||
if host not in _GITHUB_HOSTS:
|
||||
raise SkillImportError(
|
||||
"Only GitHub URLs are supported (https://github.com/... or raw.githubusercontent.com/...)"
|
||||
)
|
||||
# Update parsed and hostname to reflect the new GitHub URL
|
||||
parsed = urlparse(url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
|
||||
if host == "raw.githubusercontent.com":
|
||||
_assert_github_url(url)
|
||||
|
||||
if hostname == "raw.githubusercontent.com":
|
||||
# /owner/repo/ref/path/to/file
|
||||
bits = [p for p in parsed.path.split("/") if p]
|
||||
if len(bits) < 4:
|
||||
|
||||
+1090
-291
File diff suppressed because it is too large
Load Diff
+84
-26
@@ -17,13 +17,14 @@ close / navigation / refresh). It does NOT survive a server restart.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _Run:
|
||||
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task")
|
||||
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task", "run_id")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.buffer: list = [] # ordered SSE event strings (replay log)
|
||||
@@ -31,6 +32,9 @@ class _Run:
|
||||
self.status: str = "running" # running | done | error | stopped
|
||||
self.task: Optional[asyncio.Task] = None
|
||||
self.evict_task: Optional[asyncio.Task] = None
|
||||
# Stable across every subscription/replay of this exact detached run.
|
||||
# The browser uses it to make local cost accounting replay-idempotent.
|
||||
self.run_id: str = uuid.uuid4().hex
|
||||
|
||||
|
||||
_RUNS: Dict[str, _Run] = {}
|
||||
@@ -53,13 +57,24 @@ def _publish(run: _Run, ev: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _schedule_evict(session_id: str) -> None:
|
||||
def _wake_run_subscribers(run: _Run) -> None:
|
||||
"""Close subscribers even when the drain task never reached its body."""
|
||||
for q in list(run.subscribers):
|
||||
try:
|
||||
q.put_nowait((None, None))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _schedule_evict(session_id: str, expected_run: Optional[_Run] = None) -> None:
|
||||
"""(Re)arm a grace-period eviction for a terminal run with no subscribers.
|
||||
Identity-checked so a run that gets replaced/reused is never evicted by a
|
||||
stale timer."""
|
||||
run = _RUNS.get(session_id)
|
||||
if run is None:
|
||||
return
|
||||
if expected_run is not None and run is not expected_run:
|
||||
return
|
||||
if run.evict_task and not run.evict_task.done():
|
||||
run.evict_task.cancel()
|
||||
|
||||
@@ -85,25 +100,38 @@ def get_status(session_id: str) -> Optional[str]:
|
||||
return r.status if r else None
|
||||
|
||||
|
||||
async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
||||
def get_run_id(session_id: str) -> Optional[str]:
|
||||
"""Return the opaque identity of the current detached run, if present."""
|
||||
r = _RUNS.get(session_id)
|
||||
return r.run_id if r else None
|
||||
|
||||
|
||||
def get_active_run(session_id: str) -> Optional[_Run]:
|
||||
"""Return the exact active run currently registered for a session."""
|
||||
r = _RUNS.get(session_id)
|
||||
return r if r and r.status == "running" else None
|
||||
|
||||
|
||||
async def _drain(session_id: str, run: _Run, agen: AsyncGenerator[str, None],
|
||||
prev_task: Optional[asyncio.Task] = None) -> None:
|
||||
"""Pull every event from the wrapped generator into the run buffer, fanning
|
||||
each out to live subscribers. Runs to completion regardless of subscribers."""
|
||||
run = _RUNS.get(session_id)
|
||||
if run is None:
|
||||
return
|
||||
subscribers_woken = False
|
||||
|
||||
def _wake_subscribers() -> None:
|
||||
nonlocal subscribers_woken
|
||||
if subscribers_woken:
|
||||
return
|
||||
subscribers_woken = True
|
||||
_wake_run_subscribers(run)
|
||||
|
||||
# If this run replaced an in-flight one (rapid double-send), wait for that
|
||||
# one to fully finish first. Its CancelledError handler calls aclose(), which
|
||||
# persists its partial response — letting it complete before we start writing
|
||||
# keeps the two runs' session saves sequential instead of interleaved.
|
||||
if prev_task is not None and not prev_task.done():
|
||||
try:
|
||||
await asyncio.wait({prev_task})
|
||||
except asyncio.CancelledError:
|
||||
raise # our own cancellation — propagate
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if prev_task is not None and not prev_task.done():
|
||||
await asyncio.wait({prev_task})
|
||||
async for ev in agen:
|
||||
_publish(run, ev)
|
||||
if run.status == "running":
|
||||
@@ -116,6 +144,16 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
||||
await agen.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
# A rapid third replacement can cancel this task while it is still
|
||||
# waiting for its predecessor. Close this run's subscribers promptly,
|
||||
# but keep the task alive until the predecessor finishes so the next
|
||||
# run still observes the transitive session-save ordering barrier.
|
||||
_wake_subscribers()
|
||||
if prev_task is not None and not prev_task.done():
|
||||
try:
|
||||
await asyncio.shield(prev_task)
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("[agent-run] %s failed: %s", session_id, e, exc_info=True)
|
||||
run.status = "error"
|
||||
@@ -127,15 +165,11 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
||||
_publish(run, "data: [DONE]\n\n")
|
||||
finally:
|
||||
# Wake every subscriber with the end sentinel so their SSE closes.
|
||||
for q in list(run.subscribers):
|
||||
try:
|
||||
q.put_nowait((None, None))
|
||||
except Exception:
|
||||
pass
|
||||
_wake_subscribers()
|
||||
# Run is terminal — arm the grace timer so it (and its buffer) is
|
||||
# eventually freed even if nobody ever reconnects. subscribe() cancels
|
||||
# this on connect and re-arms on disconnect.
|
||||
_schedule_evict(session_id)
|
||||
_schedule_evict(session_id, run)
|
||||
|
||||
|
||||
def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
|
||||
@@ -145,20 +179,37 @@ def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
|
||||
prev_task: Optional[asyncio.Task] = None
|
||||
if prev:
|
||||
if prev.task and not prev.task.done():
|
||||
# A task cancelled before its first instruction never enters
|
||||
# _drain(), so its except/finally blocks cannot update status or
|
||||
# wake a response already bound to this exact run. Terminalize it
|
||||
# synchronously before cancelling; _drain's cleanup is idempotent
|
||||
# when the task had already started.
|
||||
if prev.status == "running":
|
||||
prev.status = "stopped"
|
||||
_wake_run_subscribers(prev)
|
||||
prev.task.cancel()
|
||||
prev_task = prev.task # new run awaits this before it starts writing
|
||||
if prev.evict_task and not prev.evict_task.done():
|
||||
prev.evict_task.cancel()
|
||||
run = _Run()
|
||||
_RUNS[session_id] = run
|
||||
run.task = asyncio.create_task(_drain(session_id, agen, prev_task))
|
||||
run.task = asyncio.create_task(_drain(session_id, run, agen, prev_task))
|
||||
return run
|
||||
|
||||
|
||||
async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
|
||||
async def subscribe(
|
||||
session_id: str,
|
||||
expected_run: Optional[_Run] = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Replay the run's buffer from the start, then stream live until it ends.
|
||||
Safe to call repeatedly (reconnect) and from multiple clients at once."""
|
||||
run = _RUNS.get(session_id)
|
||||
Safe to call repeatedly (reconnect) and from multiple clients at once.
|
||||
|
||||
``expected_run`` binds a lazy StreamingResponse body to the same run whose
|
||||
identity was put in its response headers. Without that binding, a rapid
|
||||
replacement between response construction and body iteration could replay
|
||||
the replacement run under the prior run's identity.
|
||||
"""
|
||||
run = expected_run or _RUNS.get(session_id)
|
||||
if run is None:
|
||||
return
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
@@ -201,12 +252,19 @@ async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
|
||||
# Last subscriber gone on a finished run — (re)arm eviction so the
|
||||
# buffer doesn't linger indefinitely.
|
||||
if not run.subscribers and run.status != "running":
|
||||
_schedule_evict(session_id)
|
||||
_schedule_evict(session_id, run)
|
||||
|
||||
|
||||
def stop(session_id: str) -> bool:
|
||||
"""Cancel an in-flight run (the wrapped generator saves its partial)."""
|
||||
def stop(session_id: str, expected_run_id: Optional[str] = None) -> bool:
|
||||
"""Cancel the matching in-flight run (which saves its partial output).
|
||||
|
||||
A stale browser may issue Stop after another tab has replaced the session's
|
||||
run. Once the caller knows its opaque run identity, fail closed rather than
|
||||
cancelling that newer run.
|
||||
"""
|
||||
run = _RUNS.get(session_id)
|
||||
if not expected_run_id or run is None or run.run_id != expected_run_id:
|
||||
return False
|
||||
if run and run.task and not run.task.done():
|
||||
run.task.cancel()
|
||||
return True
|
||||
|
||||
@@ -510,7 +510,12 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# set/get/list/delete operate on the REAL app settings (the same store
|
||||
# the Settings panel writes), so changing a model / voice / search
|
||||
# engine / reminder channel from chat actually takes effect.
|
||||
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
|
||||
from src.settings import (
|
||||
DEFAULT_SETTINGS,
|
||||
RETIRED_SETTING_KEYS,
|
||||
load_settings,
|
||||
save_settings,
|
||||
)
|
||||
|
||||
# Secrets/credentials the agent must NOT write: kept read-only (masked)
|
||||
# so API keys never flow through chat. User sets these in the panel.
|
||||
@@ -562,6 +567,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return k2
|
||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||
|
||||
def _is_managed_key(key):
|
||||
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
||||
|
||||
_ENUMS = {
|
||||
"image_quality": ["low", "medium", "high"],
|
||||
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
||||
@@ -624,14 +632,18 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
if action == "list":
|
||||
s = load_settings()
|
||||
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
|
||||
shown = {
|
||||
k: _mask(k, v)
|
||||
for k, v in s.items()
|
||||
if _is_managed_key(k) and not isinstance(v, dict)
|
||||
}
|
||||
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
||||
|
||||
elif action == "get":
|
||||
key = _resolve(args.get("key", ""))
|
||||
if not key:
|
||||
return {"error": "key is required", "exit_code": 1}
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
if not _is_managed_key(key):
|
||||
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
|
||||
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
||||
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
||||
@@ -642,11 +654,11 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if not raw:
|
||||
return {"error": "key is required", "exit_code": 1}
|
||||
key = _resolve(raw)
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
if not _is_managed_key(key):
|
||||
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
||||
if _is_secret(key):
|
||||
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
|
||||
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
|
||||
# Structured settings (dicts/lists like keybinds or vision fallbacks)
|
||||
# have no safe scalar coercion; _coerce would pass a bare string
|
||||
# straight through and clobber the structure. Refuse them here; they're
|
||||
# edited in their dedicated panels. (reset/delete still restore the
|
||||
@@ -675,7 +687,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
elif action == "delete" or action == "reset":
|
||||
key = _resolve(args.get("key", ""))
|
||||
if key not in DEFAULT_SETTINGS:
|
||||
if not _is_managed_key(key):
|
||||
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
||||
if _is_secret(key):
|
||||
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
|
||||
|
||||
+682
-105
@@ -20,6 +20,395 @@ from src.interactive_gate import wait_for_interactive_quiet
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _read_email_urgency_state(state_path):
|
||||
"""Read one atomic urgency checkpoint, tolerating the legacy shape."""
|
||||
from pathlib import Path
|
||||
|
||||
state_path = Path(state_path)
|
||||
try:
|
||||
state = (
|
||||
json.loads(state_path.read_text(encoding="utf-8"))
|
||||
if state_path.exists()
|
||||
else {}
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
return state if isinstance(state, dict) else {}
|
||||
|
||||
|
||||
def _email_urgency_account_generations(state):
|
||||
"""Return normalized per-account checkpoint/complete generations.
|
||||
|
||||
Checkpoint generations fence every accepted state mutation. Complete
|
||||
generations advance only for a non-stale complete scan. Missing metadata
|
||||
is the legacy generation zero.
|
||||
"""
|
||||
raw = state.get("account_generations", {}) if isinstance(state, dict) else {}
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
|
||||
generations = {}
|
||||
for account_id, value in raw.items():
|
||||
if isinstance(value, dict):
|
||||
checkpoint = value.get("checkpoint", 0)
|
||||
complete = value.get("complete", 0)
|
||||
else:
|
||||
# Tolerate an intermediate scalar representation as one completed
|
||||
# checkpoint generation instead of discarding its fence.
|
||||
checkpoint = value
|
||||
complete = value
|
||||
try:
|
||||
checkpoint = max(0, int(checkpoint))
|
||||
except (TypeError, ValueError):
|
||||
checkpoint = 0
|
||||
try:
|
||||
complete = max(0, int(complete))
|
||||
except (TypeError, ValueError):
|
||||
complete = 0
|
||||
generations[str(account_id)] = {
|
||||
"checkpoint": checkpoint,
|
||||
"complete": complete,
|
||||
}
|
||||
return generations
|
||||
|
||||
|
||||
def _email_urgency_string_set(value):
|
||||
if not isinstance(value, (list, tuple, set, frozenset)):
|
||||
return set()
|
||||
return {str(item) for item in value if isinstance(item, (str, int))}
|
||||
|
||||
|
||||
def _acquire_email_urgency_state_lock(
|
||||
state_path,
|
||||
lock_db_path,
|
||||
cancel_event,
|
||||
timeout_seconds=120,
|
||||
):
|
||||
"""Acquire the cross-process urgency lock without blocking the app loop."""
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
state_path = Path(state_path)
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
while not cancel_event.is_set():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise sqlite3.OperationalError("timed out waiting for urgency state lock")
|
||||
conn = sqlite3.connect(
|
||||
str(lock_db_path),
|
||||
timeout=min(0.25, max(0.01, remaining)),
|
||||
check_same_thread=False,
|
||||
)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
except sqlite3.OperationalError as exc:
|
||||
conn.close()
|
||||
if "locked" not in str(exc).lower():
|
||||
raise
|
||||
cancel_event.wait(min(0.05, max(0.0, remaining)))
|
||||
continue
|
||||
except BaseException:
|
||||
conn.close()
|
||||
raise
|
||||
|
||||
if cancel_event.is_set():
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return None, None
|
||||
return conn, _read_email_urgency_state(state_path)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _close_email_urgency_state_lock(conn):
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _commit_email_urgency_state(conn, state_path, next_state):
|
||||
"""Atomically publish JSON before releasing the SQLite write lock."""
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
state_path = Path(state_path)
|
||||
temp_path = state_path.with_name(
|
||||
f".{state_path.name}.{uuid.uuid4().hex}.tmp"
|
||||
)
|
||||
try:
|
||||
temp_path.write_text(json.dumps(next_state), encoding="utf-8")
|
||||
temp_path.replace(state_path)
|
||||
conn.commit()
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _run_email_urgency_state_transaction(
|
||||
state_path,
|
||||
lock_db_path,
|
||||
operation,
|
||||
):
|
||||
"""Serialize one urgency decision while keeping async work on this loop.
|
||||
|
||||
Only lock acquisition waits in a worker thread. ``operation`` is awaited
|
||||
on the caller's long-lived event loop, where shared async clients, locks,
|
||||
and the browser-notification queue belong. Cancellation rolls back the
|
||||
SQLite transaction and never publishes a checkpoint.
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
cancel_event = threading.Event()
|
||||
acquire_future = loop.run_in_executor(
|
||||
None,
|
||||
_acquire_email_urgency_state_lock,
|
||||
state_path,
|
||||
lock_db_path,
|
||||
cancel_event,
|
||||
)
|
||||
try:
|
||||
conn, prior = await asyncio.shield(acquire_future)
|
||||
except asyncio.CancelledError as cancelled:
|
||||
cancel_event.set()
|
||||
# The acquisition worker owns any connection until it returns. Wait
|
||||
# for its short busy-poll to observe cancellation, then close a lock it
|
||||
# may have won concurrently with the cancellation request.
|
||||
while True:
|
||||
try:
|
||||
conn, _prior = await asyncio.shield(acquire_future)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except Exception:
|
||||
conn = None
|
||||
break
|
||||
_close_email_urgency_state_lock(conn)
|
||||
raise cancelled
|
||||
|
||||
if conn is None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
try:
|
||||
result, next_state = await operation(prior)
|
||||
# Keep this small atomic publish synchronous. There is no await between
|
||||
# the successful operation and commit, so cancellation cannot be
|
||||
# observed and then followed by a checkpoint.
|
||||
try:
|
||||
_commit_email_urgency_state(conn, state_path, next_state)
|
||||
finally:
|
||||
conn = None
|
||||
return result
|
||||
except BaseException:
|
||||
_close_email_urgency_state_lock(conn)
|
||||
raise
|
||||
|
||||
|
||||
def _email_urgency_account_key(message_key):
|
||||
return str(message_key).split(":", 1)[0]
|
||||
|
||||
|
||||
def _email_urgency_payload_account_ids(state):
|
||||
"""Return account IDs that still own user-visible urgency payload."""
|
||||
if not isinstance(state, dict):
|
||||
return set()
|
||||
|
||||
per_uid = state.get("per_uid", {})
|
||||
per_uid_keys = per_uid if isinstance(per_uid, dict) else {}
|
||||
return {
|
||||
_email_urgency_account_key(key) for key in per_uid_keys
|
||||
} | {
|
||||
_email_urgency_account_key(key)
|
||||
for key in _email_urgency_string_set(state.get("notified_uids", []))
|
||||
}
|
||||
|
||||
|
||||
def _email_urgency_known_account_ids(state):
|
||||
"""Return payload owners plus generation-only active/retired markers."""
|
||||
return _email_urgency_payload_account_ids(state) | set(
|
||||
_email_urgency_account_generations(state)
|
||||
)
|
||||
|
||||
|
||||
def _email_urgency_stale_accounts(
|
||||
prior,
|
||||
base_account_generations,
|
||||
account_ids,
|
||||
):
|
||||
prior_generations = _email_urgency_account_generations(prior)
|
||||
base_generations = _email_urgency_account_generations(
|
||||
{"account_generations": base_account_generations}
|
||||
)
|
||||
return {
|
||||
str(account_id)
|
||||
for account_id in account_ids
|
||||
if prior_generations.get(str(account_id), {}).get("checkpoint", 0)
|
||||
!= base_generations.get(str(account_id), {}).get("checkpoint", 0)
|
||||
}
|
||||
|
||||
|
||||
def _merge_email_urgency_state(
|
||||
prior,
|
||||
*,
|
||||
owner,
|
||||
per_uid_scores,
|
||||
notified_uids,
|
||||
all_unread_keys,
|
||||
fully_scanned_account_ids,
|
||||
base_account_generations,
|
||||
timestamp,
|
||||
retired_account_ids=(),
|
||||
base_payload_account_ids=(),
|
||||
known_account_ids=(),
|
||||
):
|
||||
"""Merge a scan without letting an older snapshot erase newer facts."""
|
||||
prior_per_uid = prior.get("per_uid", {})
|
||||
if not isinstance(prior_per_uid, dict):
|
||||
prior_per_uid = {}
|
||||
complete = {str(account_id) for account_id in fully_scanned_account_ids}
|
||||
prior_generations = _email_urgency_account_generations(prior)
|
||||
retire_requested = {str(account_id) for account_id in retired_account_ids}
|
||||
observed_accounts = {
|
||||
_email_urgency_account_key(key) for key in per_uid_scores
|
||||
} | complete | retire_requested
|
||||
stale_accounts = _email_urgency_stale_accounts(
|
||||
prior,
|
||||
base_account_generations,
|
||||
observed_accounts,
|
||||
)
|
||||
prior_payload_accounts = _email_urgency_payload_account_ids(prior)
|
||||
base_payload_accounts = {
|
||||
str(account_id) for account_id in base_payload_account_ids
|
||||
}
|
||||
# A selected account can be absent from the base snapshot. If another
|
||||
# worker creates its first payload before this transaction wins the lock,
|
||||
# membership itself is a fence even when both snapshots normalize to the
|
||||
# legacy generation zero.
|
||||
retired_accounts = {
|
||||
account_id
|
||||
for account_id in retire_requested - stale_accounts
|
||||
if not (
|
||||
account_id in prior_payload_accounts
|
||||
and account_id not in base_payload_accounts
|
||||
)
|
||||
}
|
||||
fresh_complete = complete - stale_accounts - retired_accounts
|
||||
changed_accounts = set(fresh_complete)
|
||||
|
||||
merged_per_uid = {
|
||||
key: value
|
||||
for key, value in prior_per_uid.items()
|
||||
if _email_urgency_account_key(key) not in retired_accounts
|
||||
}
|
||||
for key in list(merged_per_uid):
|
||||
account_id = _email_urgency_account_key(key)
|
||||
if account_id in fresh_complete:
|
||||
merged_per_uid.pop(key, None)
|
||||
changed_accounts.add(account_id)
|
||||
# Partial scans may add or refresh facts, but absence from a partial scan
|
||||
# is not evidence that another checkpoint or UI row is stale. When another
|
||||
# worker committed after this scan captured its base generation, discard
|
||||
# this account's whole stale snapshot. A key absent from the newer state
|
||||
# may have been removed/read, so even a stale-only key is not safely
|
||||
# additive without another fresh scan.
|
||||
for key, value in per_uid_scores.items():
|
||||
account_id = _email_urgency_account_key(key)
|
||||
if account_id in stale_accounts or account_id in retired_accounts:
|
||||
continue
|
||||
if merged_per_uid.get(key) != value:
|
||||
changed_accounts.add(account_id)
|
||||
merged_per_uid[key] = value
|
||||
|
||||
prior_notified = _email_urgency_string_set(prior.get("notified_uids", []))
|
||||
merged_notified = {
|
||||
key
|
||||
for key in prior_notified
|
||||
if _email_urgency_account_key(key) not in retired_accounts
|
||||
}
|
||||
for key in _email_urgency_string_set(notified_uids) - prior_notified:
|
||||
account_id = _email_urgency_account_key(key)
|
||||
if account_id in stale_accounts or account_id in retired_accounts:
|
||||
continue
|
||||
merged_notified.add(key)
|
||||
changed_accounts.add(account_id)
|
||||
for key in list(merged_notified):
|
||||
if (
|
||||
_email_urgency_account_key(key) in fresh_complete
|
||||
and key not in all_unread_keys
|
||||
):
|
||||
merged_notified.discard(key)
|
||||
changed_accounts.add(_email_urgency_account_key(key))
|
||||
|
||||
next_generations = {
|
||||
account_id: dict(value)
|
||||
for account_id, value in prior_generations.items()
|
||||
}
|
||||
for account_id in changed_accounts:
|
||||
generation = next_generations.setdefault(
|
||||
account_id,
|
||||
{"checkpoint": 0, "complete": 0},
|
||||
)
|
||||
generation["checkpoint"] += 1
|
||||
if account_id in fresh_complete:
|
||||
generation["complete"] += 1
|
||||
for account_id in {str(value) for value in known_account_ids}:
|
||||
next_generations.setdefault(
|
||||
account_id,
|
||||
{"checkpoint": 0, "complete": 0},
|
||||
)
|
||||
for account_id in retired_accounts:
|
||||
# Every authoritative absence advances its generation, even when the
|
||||
# prior state is already a payload-empty tombstone. A re-enabled scan
|
||||
# may have captured that previous tombstone immediately before the
|
||||
# account was disabled/deleted again; monotonic advancement is what
|
||||
# makes that in-flight scan stale.
|
||||
generation = next_generations.setdefault(
|
||||
account_id,
|
||||
{"checkpoint": 0, "complete": 0},
|
||||
)
|
||||
generation["checkpoint"] += 1
|
||||
|
||||
total_unread = 0
|
||||
total_urgent = 0
|
||||
max_score = 0
|
||||
for value in merged_per_uid.values():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
try:
|
||||
score = max(0, min(3, int(value.get("score", 0))))
|
||||
except (TypeError, ValueError):
|
||||
score = 0
|
||||
max_score = max(max_score, score)
|
||||
if value.get("unread"):
|
||||
total_unread += 1
|
||||
if score >= 2:
|
||||
total_urgent += 1
|
||||
|
||||
return {
|
||||
"ts": timestamp,
|
||||
"owner": owner or "",
|
||||
"total_unread": total_unread,
|
||||
"total_urgent": total_urgent,
|
||||
"max_score": max_score,
|
||||
"per_uid": merged_per_uid,
|
||||
"notified_uids": sorted(merged_notified),
|
||||
"account_generations": next_generations,
|
||||
}
|
||||
|
||||
|
||||
class TaskNoop(BaseException):
|
||||
"""Raised by an action when it determined there's nothing to do.
|
||||
|
||||
@@ -1878,6 +2267,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
# filename for single-user installs (matches prior behaviour).
|
||||
_owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||
STATE_PATH = _P(DATA_DIR) / f"email_urgency_state_{_owner_slug}.json"
|
||||
STATE_LOCK_DB = STATE_PATH.with_suffix(".lock.sqlite3")
|
||||
CACHE_DIR = _P(EMAIL_URGENCY_CACHE_DIR)
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1892,35 +2282,144 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"shopping", "social", "work", "personal", "legal", "support", "promo",
|
||||
}
|
||||
|
||||
# ── 1. Resolve LLM candidates (utility primary + utility fallbacks; fall
|
||||
# through to default chat as a last resort).
|
||||
# Resolve with the task owner as before, but defer the availability
|
||||
# gate until after authoritative account cleanup. State retirement must
|
||||
# still run when no model is configured.
|
||||
from src.task_endpoint import resolve_task_candidates
|
||||
candidates = resolve_task_candidates(owner=owner)
|
||||
if not candidates:
|
||||
return "No LLM endpoint available", False
|
||||
|
||||
target_account_id = _email_task_account_id(kwargs)
|
||||
|
||||
# ── 2. Enumerate enabled accounts. Match this task's owner AND fall
|
||||
# ── 1. Enumerate enabled accounts. Match this task's owner AND fall
|
||||
# back to the legacy "unowned account whose imap_user / from_address
|
||||
# == this owner" pattern — same rule `_get_email_config` uses, so a
|
||||
# pre-multi-user account row still gets picked up for the seeded task.
|
||||
db = _SL()
|
||||
try:
|
||||
from sqlalchemy import and_ as _and, or_ as _or
|
||||
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
|
||||
if owner:
|
||||
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
|
||||
same_mailbox = _or(_EA.imap_user == owner, _EA.from_address == owner)
|
||||
q = q.filter(_or(_EA.owner == owner, _and(unowned, same_mailbox)))
|
||||
if target_account_id:
|
||||
q = q.filter(_EA.id == target_account_id)
|
||||
accounts = q.all()
|
||||
finally:
|
||||
db.close()
|
||||
def _enumerate_enabled_accounts():
|
||||
db = _SL()
|
||||
try:
|
||||
from sqlalchemy import and_ as _and, or_ as _or
|
||||
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
|
||||
if owner:
|
||||
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
|
||||
same_mailbox = _or(
|
||||
_EA.imap_user == owner,
|
||||
_EA.from_address == owner,
|
||||
)
|
||||
q = q.filter(
|
||||
_or(_EA.owner == owner, _and(unowned, same_mailbox))
|
||||
)
|
||||
if target_account_id:
|
||||
q = q.filter(_EA.id == target_account_id)
|
||||
return q.all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
initial_accounts = _enumerate_enabled_accounts()
|
||||
initial_account_ids = {
|
||||
str(account.id) for account in initial_accounts
|
||||
}
|
||||
|
||||
# Register every account before IMAP work, including its first-ever
|
||||
# scan. A concurrent zero-account cleanup can then advance this marker
|
||||
# and fence delivery even before the scan has produced payload.
|
||||
registered_state = None
|
||||
if initial_account_ids:
|
||||
async def _register_accounts(prior):
|
||||
next_state = _merge_email_urgency_state(
|
||||
prior,
|
||||
owner=owner,
|
||||
per_uid_scores={},
|
||||
notified_uids=prior.get("notified_uids", []),
|
||||
all_unread_keys=set(),
|
||||
fully_scanned_account_ids=set(),
|
||||
base_account_generations=(
|
||||
_email_urgency_account_generations(prior)
|
||||
),
|
||||
timestamp=_time.time(),
|
||||
known_account_ids=initial_account_ids,
|
||||
)
|
||||
# Return the exact state committed by registration. This is
|
||||
# the scan's generation token: adopting a later checkpoint
|
||||
# after account cleanup would let the stale scan appear fresh.
|
||||
return next_state, next_state
|
||||
|
||||
registered_state = await _run_email_urgency_state_transaction(
|
||||
STATE_PATH,
|
||||
STATE_LOCK_DB,
|
||||
_register_accounts,
|
||||
)
|
||||
|
||||
# Revalidate after registration. If deletion/disable and its cleanup
|
||||
# completed before the marker was published, this second enumeration
|
||||
# observes the absence and this action retires its own marker instead
|
||||
# of starting IMAP. Accounts newly appearing between the two reads are
|
||||
# left for the next pass rather than scanned without prior registration.
|
||||
verified_accounts = _enumerate_enabled_accounts()
|
||||
enabled_account_ids = {
|
||||
str(account.id) for account in verified_accounts
|
||||
}
|
||||
accounts = [
|
||||
account
|
||||
for account in verified_accounts
|
||||
if str(account.id) in initial_account_ids
|
||||
]
|
||||
|
||||
# Capture the checkpoint basis before cleanup or IMAP. A full
|
||||
# owner-wide enumeration authoritatively retires all known state IDs
|
||||
# absent from the current enabled/visible set. A scoped task may retire
|
||||
# only its selected missing/disabled account. Existing accounts remain
|
||||
# present even if their later network scan fails, so transient IMAP
|
||||
# failure never erases their last known state.
|
||||
base_state = (
|
||||
registered_state
|
||||
if registered_state is not None
|
||||
else _read_email_urgency_state(STATE_PATH)
|
||||
)
|
||||
base_account_generations = _email_urgency_account_generations(
|
||||
base_state
|
||||
)
|
||||
base_payload_account_ids = _email_urgency_payload_account_ids(base_state)
|
||||
known_state_account_ids = _email_urgency_known_account_ids(base_state)
|
||||
if target_account_id:
|
||||
retired_account_ids = (
|
||||
{str(target_account_id)}
|
||||
if str(target_account_id) not in enabled_account_ids
|
||||
else set()
|
||||
)
|
||||
else:
|
||||
retired_account_ids = (
|
||||
known_state_account_ids - enabled_account_ids
|
||||
)
|
||||
|
||||
if retired_account_ids:
|
||||
async def _retire_accounts(prior):
|
||||
next_state = _merge_email_urgency_state(
|
||||
prior,
|
||||
owner=owner,
|
||||
per_uid_scores={},
|
||||
notified_uids=prior.get("notified_uids", []),
|
||||
all_unread_keys=set(),
|
||||
fully_scanned_account_ids=set(),
|
||||
base_account_generations=base_account_generations,
|
||||
timestamp=_time.time(),
|
||||
retired_account_ids=retired_account_ids,
|
||||
base_payload_account_ids=base_payload_account_ids,
|
||||
)
|
||||
return None, next_state
|
||||
|
||||
await _run_email_urgency_state_transaction(
|
||||
STATE_PATH,
|
||||
STATE_LOCK_DB,
|
||||
_retire_accounts,
|
||||
)
|
||||
if not accounts:
|
||||
raise TaskNoop("no email accounts configured")
|
||||
|
||||
# ── 2. Account retirement above is state maintenance and does not
|
||||
# depend on model availability. Scanning still requires the utility
|
||||
# primary/fallback candidates resolved for this task owner.
|
||||
if not candidates:
|
||||
return "No LLM endpoint available", False
|
||||
|
||||
urgency_prompt = settings.get("urgent_email_prompt", "")
|
||||
per_uid_scores = {} # key = "<acc_id>:<uid>" → {"score": 0-3, "reason": "..."}
|
||||
all_unread_keys = set()
|
||||
@@ -1929,6 +2428,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
failed_classifications = []
|
||||
tag_write_details = []
|
||||
scanned = 0
|
||||
fully_scanned_account_ids = set()
|
||||
|
||||
def _heuristic_email_verdict(item: dict) -> dict:
|
||||
blob = (
|
||||
@@ -2024,16 +2524,27 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
def _scan_one(account=acc, cache_uids=cache.get("uids", {})):
|
||||
"""Sync IMAP work runs in a thread."""
|
||||
results = []
|
||||
scan_complete = True
|
||||
conn = _imap_connect(account.id)
|
||||
try:
|
||||
conn.select("INBOX", readonly=True)
|
||||
select_status, _select_data = conn.select("INBOX", readonly=True)
|
||||
if select_status != "OK":
|
||||
return results, False
|
||||
# Tag recent inbox mail, not only unread mail. Urgency
|
||||
# reminders below still only notify for unread messages.
|
||||
since_str = AGE_CUTOFF.strftime("%d-%b-%Y")
|
||||
status, data = conn.uid("SEARCH", None, f'(SINCE {since_str})')
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return results
|
||||
uids = data[0].split()[-30:]
|
||||
if status != "OK":
|
||||
return results, False
|
||||
if not data or not data[0]:
|
||||
return results, True
|
||||
matching_uids = data[0].split()
|
||||
if len(matching_uids) > 30:
|
||||
# The scale guard deliberately processes only the most
|
||||
# recent 30. That is a partial account snapshot, so it
|
||||
# cannot justify pruning older checkpoint facts.
|
||||
scan_complete = False
|
||||
uids = matching_uids[-30:]
|
||||
for uid_b in uids:
|
||||
uid = uid_b.decode() if isinstance(uid_b, bytes) else str(uid_b)
|
||||
key = f"{account.id}:{uid}"
|
||||
@@ -2041,12 +2552,41 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
cached_ok = isinstance(cached, dict) and cached.get("triage_version") == TRIAGE_VERSION
|
||||
results.append({"key": key, "uid": uid, "cached": cached if cached_ok else None})
|
||||
if cached_ok:
|
||||
# Already classified — skip the fetch.
|
||||
# Cached verdicts still need a lightweight FLAGS
|
||||
# refresh. Without it a cached unread message looks
|
||||
# read and its successful notification checkpoint
|
||||
# is pruned on the next pass.
|
||||
try:
|
||||
st, flag_data = conn.uid("FETCH", uid_b, "(UID FLAGS)")
|
||||
if st != "OK" or not flag_data:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
continue
|
||||
flag_parts = []
|
||||
for part in flag_data:
|
||||
if isinstance(part, (bytes, bytearray)):
|
||||
flag_parts.append(bytes(part))
|
||||
elif (
|
||||
isinstance(part, tuple)
|
||||
and part
|
||||
and isinstance(part[0], (bytes, bytearray))
|
||||
):
|
||||
flag_parts.append(bytes(part[0]))
|
||||
flags_blob = b" ".join(flag_parts)
|
||||
results[-1]["unread"] = b"\\Seen" not in flags_blob
|
||||
except Exception as _fe:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
logger.debug(
|
||||
f"urgency: flag fetch for uid {uid} failed: {_fe}"
|
||||
)
|
||||
continue
|
||||
# Pull headers + first ~800 chars of plaintext body.
|
||||
try:
|
||||
st, msg_data = conn.uid("FETCH", uid_b, "(UID FLAGS RFC822.HEADER BODY.PEEK[TEXT]<0.800>)")
|
||||
if st != "OK" or not msg_data:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
continue
|
||||
flags_blob = b" ".join(
|
||||
part[0] for part in msg_data
|
||||
@@ -2060,6 +2600,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
if isinstance(part, tuple) and part[1]:
|
||||
raw += part[1] + b"\n\n"
|
||||
if not raw:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
continue
|
||||
msg = _email_mod.message_from_bytes(raw)
|
||||
# Skip Odysseus-generated reminders so the scanner
|
||||
@@ -2115,17 +2657,21 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"unread": is_unread,
|
||||
})
|
||||
except Exception as _fe:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
logger.debug(f"urgency: header fetch for uid {uid} failed: {_fe}")
|
||||
finally:
|
||||
try: conn.logout()
|
||||
except Exception: pass
|
||||
return results
|
||||
return results, scan_complete
|
||||
|
||||
try:
|
||||
items = await _aio.to_thread(_scan_one)
|
||||
items, scan_complete = await _aio.to_thread(_scan_one)
|
||||
except Exception as e:
|
||||
logger.warning(f"urgency: IMAP scan failed for account {acc.id}: {e}")
|
||||
continue
|
||||
if scan_complete:
|
||||
fully_scanned_account_ids.add(str(acc.id))
|
||||
|
||||
for item in items:
|
||||
scanned += 1
|
||||
@@ -2262,13 +2808,13 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
logger.debug(f"urgency: LLM classify failed for {key}: {e}")
|
||||
continue
|
||||
|
||||
# ── Prune cache entries for UIDs that are no longer in the recent
|
||||
# scan window. Read messages remain cached because tags are useful
|
||||
# on read mail too; unread state is refreshed per scan above.
|
||||
seen_uids = {it["uid"] for it in items}
|
||||
cache_uids = cache.get("uids", {})
|
||||
for stale in [u for u in cache_uids if u not in seen_uids]:
|
||||
cache_uids.pop(stale, None)
|
||||
if scan_complete:
|
||||
# Only a complete account scan proves a cached UID left the
|
||||
# recent window. Partial/failing scans preserve prior facts.
|
||||
seen_uids = {it["uid"] for it in items}
|
||||
cache_uids = cache.get("uids", {})
|
||||
for stale in [u for u in cache_uids if u not in seen_uids]:
|
||||
cache_uids.pop(stale, None)
|
||||
|
||||
try:
|
||||
cache_file.write_text(_json.dumps(cache), encoding="utf-8")
|
||||
@@ -2372,40 +2918,34 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
|
||||
# ── 4. Aggregate state. urgent = score ≥ 2.
|
||||
urgent_keys = [k for k, v in per_uid_scores.items() if v.get("score", 0) >= 2 and v.get("unread")]
|
||||
max_score = max((v.get("score", 0) for v in per_uid_scores.values()), default=0)
|
||||
total_urgent = len(urgent_keys)
|
||||
|
||||
# Load prior state to know which urgent UIDs we've already notified.
|
||||
try:
|
||||
prior = _json.loads(STATE_PATH.read_text(encoding="utf-8")) if STATE_PATH.exists() else {}
|
||||
except Exception:
|
||||
prior = {}
|
||||
notified_uids = set(prior.get("notified_uids", []))
|
||||
|
||||
# ── 5. Fire reminder ONLY when a previously-unnotified UID scores urgent.
|
||||
new_urgent = [k for k in urgent_keys if k not in notified_uids]
|
||||
# ── 5. Fire a reminder only when a previously-unnotified UID scores
|
||||
# urgent. The read, decision, delivery, and checkpoint are serialized
|
||||
# below so two scheduler workers cannot both act on the same stale
|
||||
# state or overwrite each other's successful checkpoint.
|
||||
newly_notified = set()
|
||||
notify_failed = set()
|
||||
if new_urgent:
|
||||
title = "Urgent email" if total_urgent == 1 else f"{total_urgent} urgent emails"
|
||||
# Build a real listing — subject · sender · reason for each urgent
|
||||
# one — so the reminder email tells you which messages to act on,
|
||||
# not just "4 needing reply". Optional deep-link when the user has
|
||||
# `app_public_url` configured in Settings (so the email row links
|
||||
# straight into the Odysseus Email tab).
|
||||
# Sort: highest-scored UIDs first; cap at 10 to keep the email tidy.
|
||||
|
||||
def _urgency_reminder_payload(reminder_keys):
|
||||
total = len(reminder_keys)
|
||||
title = "Urgent email" if total == 1 else f"{total} urgent emails"
|
||||
sorted_urgent = sorted(
|
||||
((k, per_uid_scores[k]) for k in urgent_keys),
|
||||
key=lambda kv: kv[1].get("score", 0), reverse=True,
|
||||
((key, per_uid_scores[key]) for key in reminder_keys),
|
||||
key=lambda item: item[1].get("score", 0),
|
||||
reverse=True,
|
||||
)[:10]
|
||||
_pub = (settings.get("app_public_url") or "").strip().rstrip("/")
|
||||
from urllib.parse import quote as _quote
|
||||
lines = [f"{total_urgent} email" + ("" if total_urgent == 1 else "s") + " need an urgent reply:", ""]
|
||||
for i, (k, v) in enumerate(sorted_urgent, 1):
|
||||
subj = (v.get("subject") or "(no subject)")[:160]
|
||||
frm = v.get("from") or ""
|
||||
why = v.get("reason") or ""
|
||||
uid_for_link = str(k).split(":", 1)[-1]
|
||||
lines = [
|
||||
f"{total} email" + ("" if total == 1 else "s")
|
||||
+ " need an urgent reply:",
|
||||
"",
|
||||
]
|
||||
for i, (key, value) in enumerate(sorted_urgent, 1):
|
||||
subj = (value.get("subject") or "(no subject)")[:160]
|
||||
frm = value.get("from") or ""
|
||||
why = value.get("reason") or ""
|
||||
uid_for_link = str(key).split(":", 1)[-1]
|
||||
hash_link = f"#email={_quote('INBOX', safe='')}:{uid_for_link}"
|
||||
open_link = f"{_pub}/{hash_link}" if _pub else hash_link
|
||||
line = f"{i}. {subj}"
|
||||
@@ -2415,57 +2955,94 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
line += f" · {why}"
|
||||
lines.append(line)
|
||||
lines.append(f" Open email: {open_link}")
|
||||
if total_urgent > len(sorted_urgent):
|
||||
if total > len(sorted_urgent):
|
||||
lines.append("")
|
||||
lines.append(f"…and {total_urgent - len(sorted_urgent)} more.")
|
||||
body = "\n".join(lines)
|
||||
try:
|
||||
# Call dispatch_reminder DIRECTLY (no HTTP/auth roundtrip — the
|
||||
# endpoint version 401's the background scheduler because it
|
||||
# has no session cookie).
|
||||
from routes.note_routes import dispatch_reminder
|
||||
dispatch_result = await dispatch_reminder(
|
||||
title=title, note_body=body, note_id="urgent-email",
|
||||
owner=owner or "",
|
||||
)
|
||||
channel = (settings.get("reminder_channel") or "browser").strip().lower()
|
||||
delivered = bool(dispatch_result.get("browser_sent"))
|
||||
if channel == "email":
|
||||
delivered = bool(dispatch_result.get("email_sent"))
|
||||
elif channel == "ntfy":
|
||||
delivered = bool(dispatch_result.get("ntfy_sent"))
|
||||
elif channel == "webhook":
|
||||
delivered = bool(dispatch_result.get("webhook_sent"))
|
||||
if delivered:
|
||||
newly_notified.update(new_urgent)
|
||||
else:
|
||||
lines.append(f"…and {total - len(sorted_urgent)} more.")
|
||||
return title, "\n".join(lines)
|
||||
|
||||
async def _dispatch_urgency_reminder(reminder_keys):
|
||||
# Call dispatch_reminder directly: a scheduler has no browser
|
||||
# session cookie with which to call the HTTP endpoint.
|
||||
from routes.note_routes import dispatch_reminder
|
||||
title, body = _urgency_reminder_payload(reminder_keys)
|
||||
return await dispatch_reminder(
|
||||
title=title,
|
||||
note_body=body,
|
||||
note_id="urgent-email",
|
||||
owner=owner or "",
|
||||
)
|
||||
|
||||
async def _dispatch_and_checkpoint(prior):
|
||||
notified_uids = _email_urgency_string_set(
|
||||
prior.get("notified_uids", [])
|
||||
)
|
||||
observed_accounts = {
|
||||
_email_urgency_account_key(key) for key in per_uid_scores
|
||||
} | fully_scanned_account_ids
|
||||
stale_accounts = _email_urgency_stale_accounts(
|
||||
prior,
|
||||
base_account_generations,
|
||||
observed_accounts,
|
||||
)
|
||||
# Generation fencing must happen before delivery, not only during
|
||||
# merge. A stale-only unread UID may have been removed, read, or
|
||||
# downgraded by the newer completed scan.
|
||||
deliverable_urgent = [
|
||||
key
|
||||
for key in urgent_keys
|
||||
if _email_urgency_account_key(key) not in stale_accounts
|
||||
]
|
||||
new_urgent = [
|
||||
key
|
||||
for key in deliverable_urgent
|
||||
if key not in notified_uids
|
||||
]
|
||||
if new_urgent:
|
||||
try:
|
||||
dispatch_result = await _dispatch_urgency_reminder(
|
||||
deliverable_urgent
|
||||
)
|
||||
channel = (settings.get("reminder_channel") or "browser").strip().lower()
|
||||
delivered = bool(dispatch_result.get("browser_sent"))
|
||||
if channel == "email":
|
||||
delivered = bool(dispatch_result.get("email_sent"))
|
||||
elif channel == "ntfy":
|
||||
delivered = bool(dispatch_result.get("ntfy_sent"))
|
||||
elif channel == "webhook":
|
||||
delivered = bool(dispatch_result.get("webhook_sent"))
|
||||
if delivered:
|
||||
newly_notified.update(new_urgent)
|
||||
notified_uids.update(new_urgent)
|
||||
else:
|
||||
notify_failed.update(new_urgent)
|
||||
logger.warning(
|
||||
"urgency: reminder dispatch returned no successful "
|
||||
f"delivery path: {dispatch_result}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"urgency: reminder dispatch failed: {e}")
|
||||
notify_failed.update(new_urgent)
|
||||
logger.warning(f"urgency: reminder dispatch returned no successful delivery path: {dispatch_result}")
|
||||
except Exception as e:
|
||||
logger.warning(f"urgency: reminder dispatch failed: {e}")
|
||||
notify_failed.update(new_urgent)
|
||||
# Mark only successfully delivered UIDs as notified so a transient
|
||||
# SMTP/ntfy/browser failure retries instead of lying forever.
|
||||
notified_uids.update(newly_notified)
|
||||
|
||||
# Prune notified_uids that aren't unread anymore (so a future re-urgent
|
||||
# message with the same UID — rare but possible after archive→unarchive
|
||||
# — can re-notify). Keep only UIDs still in `all_unread_keys`.
|
||||
notified_uids = {u for u in notified_uids if u in all_unread_keys}
|
||||
next_state = _merge_email_urgency_state(
|
||||
prior,
|
||||
owner=owner,
|
||||
per_uid_scores=per_uid_scores,
|
||||
notified_uids=notified_uids,
|
||||
all_unread_keys=all_unread_keys,
|
||||
fully_scanned_account_ids=fully_scanned_account_ids,
|
||||
base_account_generations=base_account_generations,
|
||||
timestamp=_time.time(),
|
||||
)
|
||||
return notified_uids, next_state
|
||||
|
||||
state = {
|
||||
"ts": _time.time(),
|
||||
"owner": owner or "",
|
||||
"total_unread": len(all_unread_keys),
|
||||
"total_urgent": total_urgent,
|
||||
"max_score": max_score,
|
||||
"per_uid": per_uid_scores,
|
||||
"notified_uids": sorted(notified_uids),
|
||||
}
|
||||
try:
|
||||
STATE_PATH.write_text(_json.dumps(state), encoding="utf-8")
|
||||
await _run_email_urgency_state_transaction(
|
||||
STATE_PATH,
|
||||
STATE_LOCK_DB,
|
||||
_dispatch_and_checkpoint,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"urgency: state write failed: {e}")
|
||||
logger.warning(f"urgency: state transaction failed: {e}")
|
||||
|
||||
# ── 6. Activity-log summary — counts line on top, then per-tier
|
||||
# bulleted breakdown so the user can see WHICH emails ranked where
|
||||
|
||||
@@ -282,7 +282,9 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
|
||||
if essential_system:
|
||||
sys_text = essential_system[0].get("content", "")
|
||||
if len(sys_text) > 2000:
|
||||
essential_system[0] = {"role": "system", "content": sys_text[:2000] + "\n[System prompt truncated for context limits]"}
|
||||
truncated_system = dict(essential_system[0])
|
||||
truncated_system["content"] = sys_text[:2000] + "\n[System prompt truncated for context limits]"
|
||||
essential_system[0] = truncated_system
|
||||
trimmed = essential_system + convo_msgs
|
||||
if estimate_tokens(trimmed) <= budget:
|
||||
return _sanitize_tool_messages(essential_system + protected_msgs + convo_msgs)
|
||||
@@ -325,6 +327,9 @@ async def maybe_compact(
|
||||
messages: List[Dict],
|
||||
headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
persist: bool = True,
|
||||
compaction_state: Optional[Dict[str, Any]] = None,
|
||||
) -> tuple:
|
||||
"""Check context usage and compact if above threshold.
|
||||
|
||||
@@ -416,7 +421,17 @@ async def maybe_compact(
|
||||
# offset — session.history INCLUDES the system messages, but
|
||||
# split_point is indexed against convo_msgs which does NOT. Without
|
||||
# this, the slice drops the leading system message(s).
|
||||
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
|
||||
if compaction_state is not None:
|
||||
compaction_state.update({
|
||||
"split_point": split_point,
|
||||
"summary": summary,
|
||||
"system_msg_count": len(system_msgs),
|
||||
"applied": False,
|
||||
})
|
||||
if persist:
|
||||
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
|
||||
if compaction_state is not None:
|
||||
compaction_state["applied"] = True
|
||||
|
||||
new_used = estimate_tokens(compacted)
|
||||
logger.info(
|
||||
@@ -427,6 +442,51 @@ async def maybe_compact(
|
||||
return compacted, context_length, True
|
||||
|
||||
|
||||
def apply_compaction_state(session, compaction_state: Optional[Dict[str, Any]]) -> bool:
|
||||
"""Persist a route-specific compaction after that route commits output.
|
||||
|
||||
Candidate prompts may be compacted speculatively while an explicit
|
||||
foreground fallback chain is being tried. Persisting at construction time
|
||||
would let an unavailable route rewrite history before another route answers,
|
||||
so callers hold this small plan and apply only the winning route's plan.
|
||||
"""
|
||||
|
||||
state = compaction_state if isinstance(compaction_state, dict) else None
|
||||
if not state or state.get("applied"):
|
||||
return False
|
||||
summary = state.get("summary")
|
||||
split_point = state.get("split_point")
|
||||
system_msg_count = state.get("system_msg_count", 0)
|
||||
if not isinstance(summary, str) or not isinstance(split_point, int):
|
||||
return False
|
||||
_update_session_history(
|
||||
session,
|
||||
split_point,
|
||||
summary,
|
||||
system_msg_count=system_msg_count if isinstance(system_msg_count, int) else 0,
|
||||
)
|
||||
state["applied"] = True
|
||||
return True
|
||||
|
||||
|
||||
def apply_compaction_state_for_session(
|
||||
session_id: Optional[str],
|
||||
compaction_state: Optional[Dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Resolve an in-memory session and apply a deferred compaction plan."""
|
||||
|
||||
if not session_id:
|
||||
return False
|
||||
try:
|
||||
from core.models import get_session_manager_instance
|
||||
|
||||
manager = get_session_manager_instance()
|
||||
session = manager.get_session(session_id) if manager else None
|
||||
except Exception:
|
||||
session = None
|
||||
return apply_compaction_state(session, compaction_state) if session else False
|
||||
|
||||
|
||||
def _update_session_history(session, split_point: int, summary: str,
|
||||
system_msg_count: int = 0):
|
||||
"""Update the in-memory session history after compaction.
|
||||
|
||||
+215
-33
@@ -5,6 +5,7 @@ Consolidates the 4+ copies of normalize_base / resolve_endpoint logic into one p
|
||||
"""
|
||||
|
||||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -27,6 +28,43 @@ _NON_CHAT_MODEL = (
|
||||
)
|
||||
|
||||
|
||||
def endpoint_cost_tracked(url: str, endpoint_kind: Optional[str] = None) -> bool:
|
||||
"""Return whether token cost should be tracked for a concrete route.
|
||||
|
||||
This is intentionally a non-secret route classification. It mirrors the
|
||||
frontend's local/subscription exclusions without exposing endpoint URLs to
|
||||
message metadata.
|
||||
"""
|
||||
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
host = (parsed.hostname or "").lower().rstrip(".")
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
except Exception:
|
||||
return False
|
||||
if not host:
|
||||
return False
|
||||
if host == "chatgpt.com" and (
|
||||
path == "/backend-api/codex" or path.startswith("/backend-api/codex/")
|
||||
):
|
||||
return False
|
||||
kind = str(endpoint_kind or "auto").strip().lower()
|
||||
if kind == "local":
|
||||
return False
|
||||
if kind in {"api", "proxy"}:
|
||||
return True
|
||||
if host in {"localhost", "0.0.0.0", "host.docker.internal"} or host.endswith(".local"):
|
||||
return False
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
return ip.is_global
|
||||
except ValueError:
|
||||
pass
|
||||
if "." not in host:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _first_chat_model(models) -> Optional[str]:
|
||||
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
|
||||
for m in (models or []):
|
||||
@@ -396,10 +434,14 @@ def resolve_endpoint(
|
||||
db.close()
|
||||
|
||||
|
||||
def resolve_endpoint_by_id(
|
||||
ep_id: str, model: Optional[str] = None, owner: Optional[str] = None
|
||||
) -> Optional[Tuple[str, str, Dict]]:
|
||||
"""Resolve a specific endpoint id (+ optional model) to (chat_url, model, headers).
|
||||
def _resolve_endpoint_by_id_with_descriptor(
|
||||
ep_id: str,
|
||||
model: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
) -> Optional[Tuple[Tuple[str, str, Dict], dict]]:
|
||||
"""Resolve a concrete endpoint/model plus its non-secret descriptor.
|
||||
|
||||
Returns None if the endpoint doesn't exist or is disabled. Used to turn
|
||||
a configured fallback entry ({endpoint_id, model}) into a dispatch target.
|
||||
@@ -426,15 +468,34 @@ def resolve_endpoint_by_id(
|
||||
chat_url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
m = (model or "").strip()
|
||||
# Drop a model the user disabled on the endpoint, then pick the first
|
||||
# enabled chat model rather than a hidden one.
|
||||
if m and m in _endpoint_hidden_models(ep):
|
||||
m = ""
|
||||
if not m:
|
||||
m = _first_chat_model(_endpoint_enabled_models(ep)) or ""
|
||||
enabled_models = _endpoint_enabled_models(ep)
|
||||
if require_exact_model:
|
||||
# Explicit foreground fallback entries are concrete choices. A
|
||||
# hidden or known-missing model must disable the entry instead of
|
||||
# silently substituting another model from the endpoint.
|
||||
if not m or m in _endpoint_hidden_models(ep):
|
||||
return None
|
||||
if enabled_models and m not in enabled_models:
|
||||
return None
|
||||
else:
|
||||
# Legacy Utility/Vision chains retain their model-repair behavior.
|
||||
if m and m in _endpoint_hidden_models(ep):
|
||||
m = ""
|
||||
if not m:
|
||||
m = _first_chat_model(enabled_models) or ""
|
||||
if not m:
|
||||
return None
|
||||
return chat_url, m, headers
|
||||
return (
|
||||
(chat_url, m, headers),
|
||||
{
|
||||
"endpoint_id": ep.id,
|
||||
"endpoint_label": getattr(ep, "name", None) or ep.id,
|
||||
"endpoint_cost_tracked": endpoint_cost_tracked(
|
||||
chat_url,
|
||||
getattr(ep, "endpoint_kind", None),
|
||||
),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
|
||||
return None
|
||||
@@ -442,29 +503,105 @@ def resolve_endpoint_by_id(
|
||||
db.close()
|
||||
|
||||
|
||||
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
"""Build the configured default-chat fallback chain as a list of
|
||||
(chat_url, model, headers) tuples, skipping any that can't resolve.
|
||||
def resolve_endpoint_by_id(
|
||||
ep_id: str,
|
||||
model: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
) -> Optional[Tuple[str, str, Dict]]:
|
||||
"""Resolve a specific endpoint id (+ optional model) to its runtime route."""
|
||||
|
||||
The primary model is NOT included — callers prepend their session's
|
||||
current (url, model, headers) so per-session model overrides are honored.
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
ep_id,
|
||||
model,
|
||||
owner=owner,
|
||||
require_exact_model=require_exact_model,
|
||||
)
|
||||
return resolved[0] if resolved else None
|
||||
|
||||
|
||||
def resolve_route_descriptor(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Return the visible endpoint identity for an already-resolved route.
|
||||
|
||||
Headers are compared only inside the process so two endpoints using the
|
||||
same provider URL/model but different credentials remain distinguishable.
|
||||
No credential material is returned or logged.
|
||||
"""
|
||||
return _resolve_fallback_candidates("default_model_fallbacks", owner=owner)
|
||||
|
||||
if not endpoint_url or not model:
|
||||
return {
|
||||
"endpoint_id": None,
|
||||
"endpoint_label": "Selected route",
|
||||
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
|
||||
}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
expected = (endpoint_url.rstrip("/"), model, headers or {})
|
||||
for ep in q.all():
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
ep.id,
|
||||
model,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
if not resolved:
|
||||
continue
|
||||
candidate, descriptor = resolved
|
||||
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
|
||||
if actual == expected:
|
||||
return descriptor
|
||||
except Exception as e:
|
||||
logger.debug("Could not identify selected endpoint route: %s", e)
|
||||
finally:
|
||||
db.close()
|
||||
return {
|
||||
"endpoint_id": None,
|
||||
"endpoint_label": "Selected route",
|
||||
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
|
||||
}
|
||||
|
||||
|
||||
def resolve_route_descriptor_by_id(
|
||||
endpoint_id: str,
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Resolve a selected route's identity without relying on row order.
|
||||
|
||||
The explicit endpoint id is still verified against the resolved runtime
|
||||
route. This prevents stale or mismatched request metadata from being used
|
||||
for attribution while disambiguating endpoints whose routes are otherwise
|
||||
identical.
|
||||
"""
|
||||
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
endpoint_id,
|
||||
model,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
if not resolved:
|
||||
return None
|
||||
candidate, descriptor = resolved
|
||||
expected = ((endpoint_url or "").rstrip("/"), model, headers or {})
|
||||
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
|
||||
return descriptor if actual == expected else None
|
||||
|
||||
|
||||
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
"""Configured fallback chain for the Utility model (`utility_model_fallbacks`)."""
|
||||
try:
|
||||
from src.settings import get_user_setting, load_settings
|
||||
settings = load_settings()
|
||||
utility_ep = (get_user_setting("utility_endpoint_id", owner or "", settings.get("utility_endpoint_id", "")) or "").strip()
|
||||
if not utility_ep:
|
||||
utility_chain = get_user_setting("utility_model_fallbacks", owner or "", settings.get("utility_model_fallbacks") or []) or []
|
||||
if utility_chain:
|
||||
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)
|
||||
return _resolve_fallback_candidates("default_model_fallbacks", owner=owner)
|
||||
except Exception:
|
||||
pass
|
||||
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)
|
||||
|
||||
|
||||
@@ -474,17 +611,62 @@ def resolve_vision_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
|
||||
|
||||
def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list:
|
||||
out = []
|
||||
try:
|
||||
from src.settings import get_user_setting, load_settings
|
||||
settings = load_settings()
|
||||
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
|
||||
except Exception:
|
||||
return out
|
||||
for entry in chain:
|
||||
return []
|
||||
return resolve_fallback_entries(chain, owner=owner)
|
||||
|
||||
|
||||
def resolve_fallback_entries(
|
||||
entries,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
) -> list:
|
||||
"""Resolve ordered endpoint/model entries within the caller's owner scope."""
|
||||
|
||||
out = []
|
||||
for entry in entries or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
resolved = resolve_endpoint_by_id(entry.get("endpoint_id", ""), entry.get("model", ""), owner=owner)
|
||||
if resolved:
|
||||
resolved = resolve_endpoint_by_id(
|
||||
entry.get("endpoint_id", ""),
|
||||
entry.get("model", ""),
|
||||
owner=owner,
|
||||
require_exact_model=require_exact_model,
|
||||
)
|
||||
if resolved and resolved not in out:
|
||||
out.append(resolved)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_fallback_entries_with_descriptors(
|
||||
entries,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
) -> list:
|
||||
"""Resolve ordered entries while retaining safe endpoint provenance."""
|
||||
|
||||
out = []
|
||||
seen = []
|
||||
for entry in entries or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
entry.get("endpoint_id", ""),
|
||||
entry.get("model", ""),
|
||||
owner=owner,
|
||||
require_exact_model=require_exact_model,
|
||||
)
|
||||
if not resolved:
|
||||
continue
|
||||
candidate, descriptor = resolved
|
||||
if any(candidate == prior for prior in seen):
|
||||
continue
|
||||
seen.append(candidate)
|
||||
out.append((candidate, descriptor))
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Explicit foreground Chat and Agent model-routing policy."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Collection, Dict, FrozenSet, Optional, Tuple
|
||||
|
||||
from src.endpoint_resolver import (
|
||||
endpoint_cost_tracked,
|
||||
resolve_fallback_entries,
|
||||
resolve_fallback_entries_with_descriptors,
|
||||
resolve_route_descriptor,
|
||||
resolve_route_descriptor_by_id,
|
||||
)
|
||||
|
||||
_DEFAULT_FALLBACK_ENTRY_RESOLVER = resolve_fallback_entries
|
||||
|
||||
|
||||
FOREGROUND_FALLBACK_ENABLED_KEY = "foreground_fallback_enabled"
|
||||
FOREGROUND_FALLBACK_LIST_KEY = "foreground_model_fallbacks"
|
||||
FOREGROUND_AVAILABILITY_STATUSES: FrozenSet[int] = frozenset({
|
||||
408, 425, 429, 500, 502, 503, 504, 507, 508, 529,
|
||||
})
|
||||
MAX_FOREGROUND_FALLBACKS = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForegroundModelPolicy:
|
||||
"""Resolved per-user foreground fallback policy."""
|
||||
|
||||
enabled: bool = False
|
||||
fallback_candidates: Tuple[tuple, ...] = ()
|
||||
fallback_descriptors: Tuple[dict, ...] = ()
|
||||
eligible_statuses: FrozenSet[int] = FOREGROUND_AVAILABILITY_STATUSES
|
||||
fallback_on_empty: bool = False
|
||||
|
||||
|
||||
def _load_policy_preferences(owner: Optional[str]) -> dict:
|
||||
"""Load only preferences that explicitly belong to ``owner``.
|
||||
|
||||
The generic preferences loader intentionally treats a legacy flat store as
|
||||
the single-user preferences object. That compatibility must not cross an
|
||||
authentication transition: once a named owner is present, foreground
|
||||
fallback consent exists only in an actual ``_users[owner]`` dictionary.
|
||||
"""
|
||||
|
||||
from routes import prefs_routes
|
||||
|
||||
if owner is None:
|
||||
prefs = prefs_routes._load_for_user(None)
|
||||
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||
|
||||
raw = prefs_routes._load()
|
||||
users = raw.get("_users") if isinstance(raw, dict) else None
|
||||
if not isinstance(users, dict):
|
||||
return {}
|
||||
prefs = users.get(owner)
|
||||
return dict(prefs) if isinstance(prefs, dict) else {}
|
||||
|
||||
|
||||
def resolve_foreground_model_policy(
|
||||
owner: Optional[str] = None,
|
||||
allowed_models: Optional[Collection[str]] = None,
|
||||
) -> ForegroundModelPolicy:
|
||||
"""Resolve an explicit owner-scoped policy, failing closed to strict mode.
|
||||
|
||||
The policy is stored in user preferences even when authentication is
|
||||
disabled. Historical ``default_model_fallbacks`` values are deliberately
|
||||
unrelated and are never read or migrated.
|
||||
"""
|
||||
|
||||
try:
|
||||
prefs = _load_policy_preferences(owner)
|
||||
except Exception:
|
||||
return ForegroundModelPolicy()
|
||||
|
||||
if prefs.get(FOREGROUND_FALLBACK_ENABLED_KEY) is not True:
|
||||
return ForegroundModelPolicy()
|
||||
|
||||
entries = prefs.get(FOREGROUND_FALLBACK_LIST_KEY)
|
||||
if not isinstance(entries, list) or not entries:
|
||||
return ForegroundModelPolicy()
|
||||
if allowed_models is not None:
|
||||
allowed = frozenset(allowed_models)
|
||||
entries = [
|
||||
entry for entry in entries
|
||||
if (
|
||||
isinstance(entry, dict)
|
||||
and isinstance(entry.get("model"), str)
|
||||
and entry.get("model") in allowed
|
||||
)
|
||||
]
|
||||
if not entries:
|
||||
return ForegroundModelPolicy()
|
||||
entries = entries[:MAX_FOREGROUND_FALLBACKS]
|
||||
|
||||
if resolve_fallback_entries is not _DEFAULT_FALLBACK_ENTRY_RESOLVER:
|
||||
# Preserve the long-standing resolver seam used by downstream tests and
|
||||
# integrations. Production uses the descriptor-aware resolver below.
|
||||
compatibility_candidates = resolve_fallback_entries(
|
||||
entries,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
# Known limitation of this test-only seam: alignment matches on model
|
||||
# alone, so when two entries share a model and the resolver skips the
|
||||
# first, the surviving candidate inherits the skipped entry's
|
||||
# endpoint_id. Production uses the descriptor-aware branch below,
|
||||
# which is unaffected.
|
||||
resolved_routes = []
|
||||
remaining_entries = list(entries)
|
||||
for candidate in compatibility_candidates:
|
||||
matching_index = next(
|
||||
(
|
||||
index for index, entry in enumerate(remaining_entries)
|
||||
if isinstance(entry, dict)
|
||||
and entry.get("model") == candidate[1]
|
||||
),
|
||||
None,
|
||||
)
|
||||
matching_entry = (
|
||||
remaining_entries.pop(matching_index)
|
||||
if matching_index is not None
|
||||
else {}
|
||||
)
|
||||
descriptor = {
|
||||
"endpoint_id": matching_entry.get("endpoint_id"),
|
||||
"endpoint_label": matching_entry.get("endpoint_id") or "Fallback route",
|
||||
"endpoint_cost_tracked": endpoint_cost_tracked(candidate[0]),
|
||||
}
|
||||
resolved_routes.append((candidate, descriptor))
|
||||
else:
|
||||
resolved_routes = resolve_fallback_entries_with_descriptors(
|
||||
entries,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
candidates = [candidate for candidate, _descriptor in resolved_routes]
|
||||
if not candidates:
|
||||
return ForegroundModelPolicy()
|
||||
|
||||
return ForegroundModelPolicy(
|
||||
enabled=True,
|
||||
fallback_candidates=tuple(candidates),
|
||||
fallback_descriptors=tuple(
|
||||
dict(descriptor) for _candidate, descriptor in resolved_routes
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
"""Return only candidates explicitly enabled by the current user."""
|
||||
|
||||
return list(resolve_foreground_model_policy(owner).fallback_candidates)
|
||||
|
||||
|
||||
def build_foreground_model_candidates(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
owner: Optional[str] = None,
|
||||
policy: Optional[ForegroundModelPolicy] = None,
|
||||
) -> list:
|
||||
"""Build the ordered candidate list for a foreground request."""
|
||||
|
||||
policy = policy or resolve_foreground_model_policy(owner)
|
||||
primary = (endpoint_url, model, headers or {})
|
||||
candidates = [primary]
|
||||
for candidate in policy.fallback_candidates:
|
||||
if candidate not in candidates:
|
||||
candidates.append(candidate)
|
||||
return candidates
|
||||
|
||||
|
||||
def build_foreground_route_descriptors(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
owner: Optional[str] = None,
|
||||
policy: Optional[ForegroundModelPolicy] = None,
|
||||
selected_endpoint_id: Optional[str] = None,
|
||||
) -> list:
|
||||
"""Build safe route metadata parallel to foreground candidates."""
|
||||
|
||||
policy = policy or resolve_foreground_model_policy(owner)
|
||||
selected = None
|
||||
if selected_endpoint_id:
|
||||
selected = resolve_route_descriptor_by_id(
|
||||
selected_endpoint_id,
|
||||
endpoint_url,
|
||||
model,
|
||||
headers or {},
|
||||
owner=owner,
|
||||
)
|
||||
if selected is None:
|
||||
selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
|
||||
primary = (endpoint_url, model, headers or {})
|
||||
candidates = [primary]
|
||||
descriptors = [selected]
|
||||
for candidate, descriptor in zip(
|
||||
policy.fallback_candidates,
|
||||
policy.fallback_descriptors,
|
||||
):
|
||||
if candidate in candidates:
|
||||
continue
|
||||
candidates.append(candidate)
|
||||
descriptors.append(dict(descriptor))
|
||||
return descriptors
|
||||
@@ -63,8 +63,11 @@ _PASSIVE_EXACT_PATHS = {
|
||||
"/api/activity/heartbeat",
|
||||
"/api/client-perf",
|
||||
"/api/tasks/notifications",
|
||||
"/api/tasks/runs/recent",
|
||||
"/api/research/active",
|
||||
"/api/email/urgency-state",
|
||||
# UI idle poll sibling of urgency-state; must not pre-empt background tasks.
|
||||
"/api/email/unread-state",
|
||||
}
|
||||
|
||||
_PASSIVE_PREFIXES = (
|
||||
@@ -74,6 +77,19 @@ _PASSIVE_PREFIXES = (
|
||||
)
|
||||
|
||||
|
||||
async def maybe_stop_background_tasks_for_heartbeat(stop_background) -> bool:
|
||||
"""Stop background work for browser activity only when the gate is enabled.
|
||||
|
||||
``stop_background`` is injected by the application boundary so this policy
|
||||
remains independently testable without importing the full FastAPI app.
|
||||
"""
|
||||
if not _enabled():
|
||||
return False
|
||||
|
||||
await stop_background(reason="browser heartbeat")
|
||||
return True
|
||||
|
||||
|
||||
def should_track_interactive_request(path: str, method: str = "GET") -> bool:
|
||||
if not _enabled():
|
||||
return False
|
||||
|
||||
+960
-137
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ class ChatRequest(BaseModel):
|
||||
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
|
||||
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
|
||||
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
|
||||
selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID")
|
||||
|
||||
@field_validator('message')
|
||||
@classmethod
|
||||
|
||||
+25
-8
@@ -14,6 +14,13 @@ from src.constants import SETTINGS_FILE, FEATURES_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keys retained in the raw settings store for compatibility and rollback, but
|
||||
# deliberately unavailable through generic settings APIs or agent tools. They
|
||||
# must stay in ``DEFAULT_SETTINGS`` so old files continue to load without data
|
||||
# loss; callers that present or mutate settings should use this set as a
|
||||
# tombstone boundary.
|
||||
RETIRED_SETTING_KEYS = frozenset({"default_model_fallbacks"})
|
||||
|
||||
# Tiny TTL cache for settings/features. get_setting() is called on hot paths
|
||||
# (every chat, every preprocess); without this it re-parses the JSON each call.
|
||||
# Picks up edits within _CACHE_TTL seconds, which is fine for human-edited config.
|
||||
@@ -138,14 +145,13 @@ DEFAULT_SETTINGS = {
|
||||
# Email replies use email_writing_style instead because greetings,
|
||||
# signatures, and mailbox identity rules are medium-specific.
|
||||
"document_writing_style": "",
|
||||
# Ordered fallback chain for the default chat model. Each entry is
|
||||
# {"endpoint_id": "...", "model": "..."}. If the primary model fails
|
||||
# before producing output (endpoint offline / errors), the chat
|
||||
# dispatch retries the next entry in order.
|
||||
# Legacy ordered fallback chain for the default chat model. Values remain
|
||||
# stored for compatibility and rollback reference, but model routing no
|
||||
# longer reads this key.
|
||||
"default_model_fallbacks": [],
|
||||
# When True, non-admin users inherit global default model/endpoint/fallbacks
|
||||
# when they have no personal defaults. When False, users only use their
|
||||
# personal defaults (no global fallback). Default is False.
|
||||
# When True, non-admin users inherit the global default model/endpoint when
|
||||
# they have no personal defaults. When False, users only use their personal
|
||||
# defaults. Default is False.
|
||||
"share_defaults_with_users": False,
|
||||
"utility_endpoint_id": "",
|
||||
"utility_model": "",
|
||||
@@ -198,6 +204,17 @@ DEFAULT_SETTINGS = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def without_retired_settings(settings: dict) -> dict:
|
||||
"""Return a shallow copy suitable for generic settings interfaces."""
|
||||
if not isinstance(settings, dict):
|
||||
return {}
|
||||
return {
|
||||
key: value
|
||||
for key, value in settings.items()
|
||||
if key not in RETIRED_SETTING_KEYS
|
||||
}
|
||||
|
||||
DEFAULT_FEATURES = {
|
||||
"web_search": True,
|
||||
"web_fetch": True,
|
||||
@@ -270,7 +287,7 @@ _PER_USER_KEYS = {
|
||||
# Default chat endpoint / model — without per-user resolution every new
|
||||
# account inherited whatever the most-recent admin picked, which then
|
||||
# got injected into the chat composer on first open.
|
||||
"default_endpoint_id", "default_model", "default_model_fallbacks",
|
||||
"default_endpoint_id", "default_model",
|
||||
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
||||
"research_endpoint_id", "research_model",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Shared resolver for background-task AI endpoints."""
|
||||
|
||||
from src.endpoint_resolver import (
|
||||
resolve_chat_fallback_candidates,
|
||||
resolve_endpoint,
|
||||
resolve_utility_fallback_candidates,
|
||||
)
|
||||
@@ -32,7 +31,6 @@ def resolve_task_candidates(
|
||||
2. Utility endpoint/model
|
||||
3. Default endpoint/model
|
||||
4. Utility fallback chain
|
||||
5. Default fallback chain
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
@@ -49,9 +47,6 @@ def resolve_task_candidates(
|
||||
_append(*resolve_endpoint("default", owner=owner))
|
||||
for url, model, headers in resolve_utility_fallback_candidates(owner=owner):
|
||||
_append(url, model, headers)
|
||||
for url, model, headers in resolve_chat_fallback_candidates(owner=owner):
|
||||
_append(url, model, headers)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
|
||||
@@ -233,7 +233,8 @@ async def _call_teacher(teacher_model_spec: str, prompt: str,
|
||||
owner: Optional[str] = None) -> Optional[str]:
|
||||
"""Call the configured teacher endpoint with the escalation prompt."""
|
||||
from src.llm_core import llm_call_async
|
||||
from src.ai_interaction import _resolve_model, _TEACHER_SYSTEM_PROMPT
|
||||
from src.ai_interaction import _resolve_model
|
||||
from src.agent_tools.model_interaction_tools import _TEACHER_SYSTEM_PROMPT
|
||||
try:
|
||||
url, model, headers = await asyncio.to_thread(_resolve_model, teacher_model_spec, owner=owner)
|
||||
except Exception as e:
|
||||
|
||||
+64
-3
@@ -193,7 +193,7 @@ _QWEN_ROLE_MARKER_RE = re.compile(r"</?\|(?:assistant|assistan|user|system|tool)
|
||||
# one; see #5547. `|end`, `end|`, `|end|` and `/|end|` still strip as before.
|
||||
_QWEN_BARE_MARKER_RE = re.compile(
|
||||
r"(?:^|[\t\r\n ])(?:/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|"
|
||||
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
|
||||
r"(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -929,6 +929,46 @@ def _parse_xml_direct_tool(name, body) -> Optional[ToolBlock]:
|
||||
return function_call_to_tool_block(mapped, json.dumps(params))
|
||||
|
||||
|
||||
def _looks_like_json_body(body: str) -> bool:
|
||||
"""True when a <tool_call> wrapper body is JSON, not XML markup."""
|
||||
return body.lstrip()[:1] in ("{", "[")
|
||||
|
||||
|
||||
def _parse_json_tool_call_body(body: str) -> Optional[ToolBlock]:
|
||||
"""Parse a Qwen/Hermes text-mode wrapper body: bare JSON inside <tool_call>.
|
||||
|
||||
<tool_call>
|
||||
{"name": "bash", "arguments": {"command": "mkdir -p agent-test"}}
|
||||
</tool_call>
|
||||
|
||||
Strict by design (issue #5187 / tracker #5333): the body must decode to an
|
||||
object with a string "name", and "arguments" — when present — must itself
|
||||
be an object. Anything else returns None rather than being coerced, so a
|
||||
malformed call is dropped instead of dispatching with mangled arguments.
|
||||
raw_decode tolerates trailing chatter after the JSON object; the trailing
|
||||
text is never scanned for tool markup. Conversion goes through
|
||||
function_call_to_tool_block so aliases and per-tool argument formatting
|
||||
stay identical to the XML invoke path.
|
||||
"""
|
||||
stripped = body.strip()
|
||||
if not stripped.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
parsed, _end = json.JSONDecoder().raw_decode(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
name = parsed.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
if "arguments" in parsed and not isinstance(parsed["arguments"], dict):
|
||||
return None
|
||||
args = parsed.get("arguments", {})
|
||||
from src.tool_schemas import function_call_to_tool_block
|
||||
return function_call_to_tool_block(name.strip().lower(), json.dumps(args))
|
||||
|
||||
|
||||
def _iter_stepfun_tool_calls(text: str):
|
||||
"""Yield StepFun native tool-call token bodies without regex backtracking."""
|
||||
pos = 0
|
||||
@@ -1330,10 +1370,21 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
if blocks:
|
||||
return blocks
|
||||
# Try wrapped: <tool_call><invoke ...>...</invoke></tool_call>
|
||||
# A wrapper body that is JSON (Qwen/Hermes text mode, issue #5187) is
|
||||
# parsed as JSON or dropped — never scanned by the XML iterators, so
|
||||
# XML-like text inside JSON argument values stays data instead of
|
||||
# selecting a different tool.
|
||||
json_body_seen = False
|
||||
for _ms, inner_start, inner_end, _me in _iter_delimited(
|
||||
text, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE
|
||||
):
|
||||
body = text[inner_start:inner_end]
|
||||
if _looks_like_json_body(body):
|
||||
json_body_seen = True
|
||||
block = _parse_json_tool_call_body(body)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
continue
|
||||
for inv_name, inv_body in _iter_xml_invoke(body):
|
||||
block = _parse_xml_invoke(inv_name, inv_body)
|
||||
if block:
|
||||
@@ -1348,6 +1399,13 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
if not blocks:
|
||||
for m in _XML_OPEN_TOOL_CALL_RE.finditer(text):
|
||||
body = m.group(1)
|
||||
if _looks_like_json_body(body):
|
||||
# Same fail-closed rule as above for an unclosed wrapper.
|
||||
json_body_seen = True
|
||||
block = _parse_json_tool_call_body(body)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
break
|
||||
for inv_name, inv_body in _iter_xml_invoke(body):
|
||||
block = _parse_xml_invoke(inv_name, inv_body)
|
||||
if block:
|
||||
@@ -1358,8 +1416,11 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
block = _parse_xml_direct_tool(d_name, d_body)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
# Try bare <invoke> without wrapper
|
||||
if not blocks:
|
||||
# Try bare <invoke> without wrapper. Skipped when a JSON wrapper body
|
||||
# was seen but produced no block: this rescan covers the full text,
|
||||
# wrapper bodies included, and <invoke> markup inside a (possibly
|
||||
# malformed) JSON payload must stay data rather than dispatch.
|
||||
if not blocks and not json_body_seen:
|
||||
for inv_name, inv_body in _iter_xml_invoke(text):
|
||||
block = _parse_xml_invoke(inv_name, inv_body)
|
||||
if block:
|
||||
|
||||
@@ -196,6 +196,9 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
try:
|
||||
if action == "list_calendars":
|
||||
_ensure_default_calendar(db, owner)
|
||||
# This read path intentionally persists the lazily-created default;
|
||||
# event creation commits it in the event's transaction instead.
|
||||
db.commit()
|
||||
cals = _calendar_query().all()
|
||||
result = [{"name": c.name, "href": c.id} for c in cals]
|
||||
if result:
|
||||
|
||||
+105
-38
@@ -35,6 +35,16 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UploadIndexFileSignature = tuple[
|
||||
str,
|
||||
Optional[int],
|
||||
Optional[int],
|
||||
Optional[int],
|
||||
Optional[int],
|
||||
Optional[int],
|
||||
]
|
||||
UploadIndexSignature = tuple[UploadIndexFileSignature, ...]
|
||||
|
||||
|
||||
class UploadCleanupSafetyError(RuntimeError):
|
||||
"""Raised when cleanup cannot prove that destructive work is safe."""
|
||||
@@ -242,7 +252,7 @@ class UploadHandler:
|
||||
|
||||
# In-memory index cache to avoid O(N) disk I/O on every request
|
||||
self._index_cache: Optional[Dict[str, Any]] = None
|
||||
self._index_mtime: float = 0.0
|
||||
self._index_signature: Optional[UploadIndexSignature] = None
|
||||
|
||||
def inside_base_dir(self, path: str) -> bool:
|
||||
"""Check if path is inside base directory"""
|
||||
@@ -727,62 +737,119 @@ class UploadHandler:
|
||||
# Update cache if this is the main index
|
||||
if path.endswith("uploads.json"):
|
||||
self._index_cache = data
|
||||
self._index_signature = self._upload_index_signature(
|
||||
(path, path + ".bak")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _upload_index_signature(
|
||||
paths: tuple[str, ...],
|
||||
) -> Optional[UploadIndexSignature]:
|
||||
"""Return file identities strong enough to validate the index cache.
|
||||
|
||||
Modification time alone is insufficient: a torn write can change a
|
||||
file without receiving a strictly newer timestamp on some filesystems.
|
||||
Size, inode, and nanosecond change times make those mutations visible
|
||||
while preserving the cache fast path for unchanged files.
|
||||
"""
|
||||
signature: list[UploadIndexFileSignature] = []
|
||||
for candidate in paths:
|
||||
try:
|
||||
self._index_mtime = os.path.getmtime(path)
|
||||
stat_result = os.stat(candidate)
|
||||
except FileNotFoundError:
|
||||
signature.append((candidate, None, None, None, None, None))
|
||||
continue
|
||||
except OSError:
|
||||
self._index_mtime = time.time()
|
||||
return None
|
||||
signature.append(
|
||||
(
|
||||
candidate,
|
||||
stat_result.st_dev,
|
||||
stat_result.st_ino,
|
||||
stat_result.st_size,
|
||||
stat_result.st_mtime_ns,
|
||||
stat_result.st_ctime_ns,
|
||||
)
|
||||
)
|
||||
return tuple(signature)
|
||||
|
||||
def _load_upload_index(self, *, fail_on_error: bool = False) -> Dict[str, Any]:
|
||||
"""Load the upload index from disk/cache. Uses mtime-based validation
|
||||
to avoid redundant parsing on hot paths. When ``fail_on_error`` is
|
||||
true, a missing, malformed, or unreadable live index raises so
|
||||
destructive callers cannot mistake corruption for an empty store.
|
||||
"""Load the upload index from disk/cache. Uses file-identity validation
|
||||
to avoid redundant parsing on hot paths without missing same-timestamp
|
||||
mutations. When ``fail_on_error`` is true, a missing, malformed, or
|
||||
unreadable live index raises so destructive callers cannot mistake
|
||||
corruption for an empty store.
|
||||
"""
|
||||
uploads_db_path = os.path.join(self.upload_dir, "uploads.json")
|
||||
candidates = (uploads_db_path, uploads_db_path + ".bak")
|
||||
if fail_on_error:
|
||||
# A backup is intentionally the previous snapshot. It is useful for
|
||||
# non-destructive reads, but cannot authorize deletion when the live
|
||||
# index is missing or corrupt.
|
||||
if not os.path.exists(uploads_db_path):
|
||||
raise ValueError("live uploads database is missing")
|
||||
existing_candidates = [uploads_db_path]
|
||||
else:
|
||||
existing_candidates = [path for path in candidates if os.path.exists(path)]
|
||||
if not existing_candidates:
|
||||
self._index_cache = {}
|
||||
self._index_mtime = 0.0
|
||||
return {}
|
||||
for _attempt in range(3):
|
||||
signature = self._upload_index_signature(candidates)
|
||||
if fail_on_error:
|
||||
# A backup is intentionally the previous snapshot. It is useful for
|
||||
# non-destructive reads, but cannot authorize deletion when the live
|
||||
# index is missing or corrupt.
|
||||
if not os.path.exists(uploads_db_path):
|
||||
raise ValueError("live uploads database is missing")
|
||||
existing_candidates = [uploads_db_path]
|
||||
else:
|
||||
existing_candidates = [
|
||||
path for path in candidates if os.path.exists(path)
|
||||
]
|
||||
if not existing_candidates:
|
||||
self._index_cache = {}
|
||||
self._index_signature = signature
|
||||
return {}
|
||||
|
||||
# Check cache validity
|
||||
try:
|
||||
mtime = max(os.path.getmtime(path) for path in existing_candidates)
|
||||
# Check cache validity
|
||||
if (
|
||||
not fail_on_error
|
||||
and signature is not None
|
||||
and self._index_cache is not None
|
||||
and mtime <= self._index_mtime
|
||||
and signature == self._index_signature
|
||||
):
|
||||
return self._index_cache
|
||||
except OSError:
|
||||
mtime = 0.0
|
||||
|
||||
# Try the live file first, fall back to the .bak sibling if the
|
||||
# live file is truncated/corrupted.
|
||||
for candidate in existing_candidates:
|
||||
try:
|
||||
with open(candidate, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
self._index_cache = data
|
||||
self._index_mtime = mtime
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read uploads database ({candidate}): {e}")
|
||||
# Try the live file first, fall back to the .bak sibling if the
|
||||
# live file is truncated/corrupted. A candidate parsed from an old
|
||||
# inode is accepted only when the whole index signature stays
|
||||
# stable through the read; otherwise retry so the cache cannot pair
|
||||
# stale data with a fresh replacement signature.
|
||||
index_changed_during_read = False
|
||||
for candidate in existing_candidates:
|
||||
try:
|
||||
with open(candidate, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
verified_signature = self._upload_index_signature(candidates)
|
||||
if (
|
||||
signature is not None
|
||||
and verified_signature is not None
|
||||
and verified_signature != signature
|
||||
):
|
||||
index_changed_during_read = True
|
||||
break
|
||||
if isinstance(data, dict):
|
||||
self._index_cache = data
|
||||
self._index_signature = verified_signature
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read uploads database ({candidate}): {e}")
|
||||
verified_signature = self._upload_index_signature(candidates)
|
||||
if (
|
||||
signature is not None
|
||||
and verified_signature is not None
|
||||
and verified_signature != signature
|
||||
):
|
||||
index_changed_during_read = True
|
||||
break
|
||||
continue
|
||||
if index_changed_during_read:
|
||||
continue
|
||||
break
|
||||
|
||||
if fail_on_error:
|
||||
raise ValueError("live uploads database is unreadable")
|
||||
self._index_cache = {}
|
||||
self._index_signature = self._upload_index_signature(candidates)
|
||||
return {}
|
||||
|
||||
def get_upload_info(self, upload_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
+6
-45
@@ -28,6 +28,7 @@ import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
import censorModule from './js/censor.js';
|
||||
import galleryModule from './js/gallery.js';
|
||||
import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js';
|
||||
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
|
||||
import calendarModule from './js/calendar.js';
|
||||
import notesModule from './js/notes.js';
|
||||
@@ -2725,46 +2726,6 @@ function initializeEventListeners() {
|
||||
// ── UI Visibility (Customize UI modal) ──
|
||||
const UI_VIS_KEY = 'odysseus-ui-visibility';
|
||||
|
||||
// Selector map: key → CSS selector(s) for targets
|
||||
const UI_VIS_MAP = {
|
||||
'sidebar-brand': '.sidebar-brand-title',
|
||||
'sidebar-new-chat': '#sidebar-new-chat-btn',
|
||||
'sidebar-search': '#sidebar-search-btn',
|
||||
'sessions-section': '#sessions-section',
|
||||
'email-section': '#email-section',
|
||||
'tools-section': '#tools-section',
|
||||
// Per-tool visibility — fine-grained control over which entries show
|
||||
// inside the Tools section in the sidebar.
|
||||
'tool-calendar': '#tool-calendar-btn',
|
||||
'tool-compare': '#tool-compare-btn',
|
||||
'tool-cookbook': '#tool-cookbook-btn',
|
||||
'tool-research': '#tool-research-btn',
|
||||
'tool-gallery': '#tool-gallery-btn',
|
||||
'tool-library': '#tool-library-btn',
|
||||
'tool-memory': '#tool-memory-btn',
|
||||
'tool-notes': '#tool-notes-btn',
|
||||
'tool-tasks': '#tool-tasks-btn',
|
||||
'tool-theme': '#tool-theme-btn',
|
||||
'user-bar': '#user-bar-profile',
|
||||
'sidebar-settings-btn':'#user-bar-settings',
|
||||
'chat-meta': '.chat-meta-overlay',
|
||||
'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
|
||||
'incognito-btn': '.incognito-btn',
|
||||
'web-toggle-btn': '#web-toggle-btn',
|
||||
'doc-toggle-btn': '#overflow-doc-btn',
|
||||
'rag-toggle-btn': '#overflow-rag-btn',
|
||||
'bash-toggle-btn': '#bash-toggle-btn',
|
||||
'overflow-plus-btn': '.overflow-wrapper',
|
||||
'mode-toggle': '.mode-toggle',
|
||||
'preset-mini-btn': '#overflow-preset-btn',
|
||||
'attach-btn': '#overflow-attach-btn',
|
||||
'research-btn': '#overflow-research-btn',
|
||||
'rail-new-chat': '#rail-new-session',
|
||||
};
|
||||
|
||||
// Keys hidden by default on first run (no localStorage yet)
|
||||
const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||
|
||||
// Keys that need admin to toggle off (reserved for future use)
|
||||
const UI_VIS_ADMIN_ONLY = new Set([]);
|
||||
|
||||
@@ -2777,14 +2738,14 @@ function initializeEventListeners() {
|
||||
}
|
||||
|
||||
function applyUIVis(state) {
|
||||
Object.entries(UI_VIS_MAP).forEach(([key, selector]) => {
|
||||
// section-drag-reorder uses a body class instead of inline styles
|
||||
if (key === 'section-drag-reorder') return;
|
||||
const visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
|
||||
// resolveVisibility computes selector→visible (pure; ui_visibility.js),
|
||||
// including the tools-section parent rule that hides every tool rail
|
||||
// launcher when Tools is off. Apply the result to the DOM here.
|
||||
for (const [selector, visible] of Object.entries(resolveVisibility(state))) {
|
||||
document.querySelectorAll(selector).forEach(el => {
|
||||
el.style.display = visible ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
// Drag reorder: use body class so dynamically created handles are covered
|
||||
const dragEnabled = state['section-drag-reorder'] === true;
|
||||
document.body.classList.toggle('rearrange-mode', dragEnabled);
|
||||
|
||||
+9
-7
@@ -248,6 +248,15 @@
|
||||
}, { once: true });
|
||||
})();
|
||||
</script>
|
||||
<!-- Preload the two faces first paint actually uses: Fira Code 400 and 600,
|
||||
the app font and the weight the sidebar and header text render at. They
|
||||
are declared in style.css, so without a hint they are only discovered
|
||||
after the stylesheet parses and then queue behind the module graph.
|
||||
crossorigin is required even though these are same-origin: fonts are
|
||||
always fetched in CORS mode, and a preload whose mode does not match the
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260808startupshell1">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
|
||||
@@ -1495,13 +1504,6 @@
|
||||
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-defaultModelSelect" class="settings-select"></select>
|
||||
</div>
|
||||
<div class="settings-row" style="align-items:flex-start;">
|
||||
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
|
||||
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+512
-118
@@ -29,9 +29,16 @@ import {
|
||||
createThinkingAnalysisGate,
|
||||
stripLiveThinkingTags,
|
||||
} from './liveThinkingThrottle.js';
|
||||
import {
|
||||
applyModelMetricsState,
|
||||
applyModelRouteEventState,
|
||||
inheritModelRouteState,
|
||||
} from './chatModelProvenance.js';
|
||||
import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js';
|
||||
|
||||
const RESEARCH_TIMEOUT_MS = 360000;
|
||||
const DEFAULT_TIMEOUT_MS = 120000;
|
||||
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
|
||||
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||
|
||||
let API_BASE = '';
|
||||
@@ -394,13 +401,27 @@ import {
|
||||
const tsSpan = roleEl.querySelector('.role-timestamp');
|
||||
const req = requestedModel || actualModel || '';
|
||||
const actual = actualModel || requestedModel || '';
|
||||
let label = _modelRouteLabel(req, actual);
|
||||
let label = _modelRouteLabel(
|
||||
req,
|
||||
actual,
|
||||
opts.requestedEndpointLabel,
|
||||
opts.actualEndpointLabel,
|
||||
opts.requestedEndpointId,
|
||||
opts.actualEndpointId,
|
||||
);
|
||||
if (opts.suffix) label += ' (' + opts.suffix + ')';
|
||||
if (opts.characterName) label = opts.characterName;
|
||||
roleEl.textContent = label + ' ';
|
||||
_applyModelColor(roleEl, actual || req);
|
||||
if (req && actual && !_sameModelName(req, actual)) {
|
||||
roleEl.title = req + ' -> ' + actual + (opts.reason ? ': ' + opts.reason : '');
|
||||
const endpointChanged = Boolean(
|
||||
opts.requestedEndpointId
|
||||
&& opts.actualEndpointId
|
||||
&& opts.requestedEndpointId !== opts.actualEndpointId
|
||||
);
|
||||
if (req && actual && (!_sameModelName(req, actual) || endpointChanged)) {
|
||||
roleEl.title = req + ' -> ' + actual
|
||||
+ (endpointChanged ? ' (' + opts.requestedEndpointLabel + ' -> ' + opts.actualEndpointLabel + ')' : '')
|
||||
+ (opts.reason ? ': ' + opts.reason : '');
|
||||
} else if (!opts.reason) {
|
||||
roleEl.removeAttribute('title');
|
||||
}
|
||||
@@ -570,6 +591,11 @@ import {
|
||||
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
|
||||
const _activeStreams = new Map(); // sessionId -> { abortCtrl, holder, query, startedAt, cancelViewWork, finalizeView }
|
||||
const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock)
|
||||
const _terminalSavedStreams = new Set(); // sessionId -> canonical terminal event seen by active reader
|
||||
const _streamRunIds = new Map(); // sessionId -> opaque identity of the current send's detached run
|
||||
const _streamGenerations = new Map(); // sessionId -> generation of the current (latest) send
|
||||
const _sendStates = new Map(); // sessionId -> { generation, abortCtrl } of the current send, installed synchronously at send commit so Stop never has to borrow an older send's controller
|
||||
const _pendingRunStops = new Map(); // 'sessionId:generation' -> abortCtrl|null; Stop queued for that send while it awaits headers. Keyed per send so concurrent sends' cancellation intents never displace each other.
|
||||
let _streamSessionId = null; // Session ID for the currently active reader loop
|
||||
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
|
||||
let _webLockRelease = null; // Function to release the Web Lock held during streaming
|
||||
@@ -608,6 +634,60 @@ import {
|
||||
return now;
|
||||
}
|
||||
|
||||
/** Stable cost identity for one logical metrics segment within a run. */
|
||||
function _metricsCostRecordId(runId, event) {
|
||||
if (!runId) return '';
|
||||
return `${runId}:${event && event.teacher ? 'teacher' : 'primary'}`;
|
||||
}
|
||||
|
||||
/** POST the exact Stop for one observed run identity. */
|
||||
function _postExactStop(sessionId, runId) {
|
||||
fetch(`/api/chat/stop/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Odysseus-Run-Id': runId },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/** Stop only the exact detached run whose identity this browser observed. */
|
||||
function _stopExactRun(sessionId, abortCtrl = null) {
|
||||
if (!sessionId) return false;
|
||||
const runId = _streamRunIds.get(sessionId);
|
||||
if (!runId) {
|
||||
// Queue against the CURRENT send's generation: its POST is the only
|
||||
// identity channel that can name the run, so the Stop fires from that
|
||||
// send's own header arrival even if a replacement starts meanwhile.
|
||||
const generation = _streamGenerations.get(sessionId) || 0;
|
||||
const pendingKey = sessionId + ':' + generation;
|
||||
if (abortCtrl || !_pendingRunStops.has(pendingKey)) {
|
||||
_pendingRunStops.set(pendingKey, abortCtrl);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
_postExactStop(sessionId, runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _rememberStreamRunId(sessionId, runId, generation) {
|
||||
if (!sessionId || !runId) return;
|
||||
// A superseded send must not record its run id as the session's current
|
||||
// identity, but it must still flush its own queued Stop: this is the only
|
||||
// channel that can cancel that run when the replacement dies before its
|
||||
// own POST reaches the server.
|
||||
if (_streamGenerations.get(sessionId) === generation) {
|
||||
_streamRunIds.set(sessionId, runId);
|
||||
}
|
||||
const pendingKey = sessionId + ':' + generation;
|
||||
if (!_pendingRunStops.has(pendingKey)) return;
|
||||
const pendingAbort = _pendingRunStops.get(pendingKey);
|
||||
_pendingRunStops.delete(pendingKey);
|
||||
_postExactStop(sessionId, runId);
|
||||
if (pendingAbort && !pendingAbort.signal.aborted) {
|
||||
pendingAbort._reason = 'user-stop';
|
||||
pendingAbort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
// Sources box builder and toggleSources are now in chatRenderer.js
|
||||
var _buildSourcesBox = chatRenderer.buildSourcesBox;
|
||||
|
||||
@@ -1342,6 +1422,26 @@ import {
|
||||
if (messageInput) messageInput.disabled = false;
|
||||
updateSubmitButton('streaming', submitBtn);
|
||||
if (submitBtn) submitBtn.classList.remove('send-pending');
|
||||
// Per-send generation, reserved SYNCHRONOUSLY before the send gate clears
|
||||
// and before the first await: from this instant the superseded send may
|
||||
// not clean session state, register, or POST (each checked at its own
|
||||
// await boundaries). Session-keyed state (run id, queued Stop, cleanup
|
||||
// rights) belongs to the latest generation only. A queued Stop from the
|
||||
// superseded send is deliberately left in place, tagged with ITS
|
||||
// generation: that send's still-alive POST is the only identity channel
|
||||
// able to name its run, so the Stop fires from its own header arrival
|
||||
// (see _rememberStreamRunId) even if this replacement dies before fetch.
|
||||
const streamSessionId = sessionModule.getCurrentSessionId();
|
||||
const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;
|
||||
_streamGenerations.set(streamSessionId, streamGeneration);
|
||||
const _sendState = { generation: streamGeneration, abortCtrl: null };
|
||||
_sendStates.set(streamSessionId, _sendState);
|
||||
// The previous send's run identity dies with its ownership: a Stop after
|
||||
// this instant must queue for THIS send, not fire against the old run.
|
||||
// (The old send's own queued Stop still works — its flush carries the run
|
||||
// id from its header, and its stale generation cannot repopulate this map.)
|
||||
_streamRunIds.delete(streamSessionId);
|
||||
_streamSessionId = streamSessionId;
|
||||
_sendInFlight = false;
|
||||
|
||||
try {
|
||||
@@ -1350,10 +1450,12 @@ import {
|
||||
await pendingSwitch;
|
||||
}
|
||||
} catch (_) {}
|
||||
// Superseded while awaiting the model switch: the replacement owns the
|
||||
// session now, and everything below (state resets, registration, POST)
|
||||
// is its business alone.
|
||||
if (_streamGenerations.get(streamSessionId) !== streamGeneration) return;
|
||||
|
||||
// Capture session ID for background stream detection
|
||||
const streamSessionId = sessionModule.getCurrentSessionId();
|
||||
_streamSessionId = streamSessionId;
|
||||
_terminalSavedStreams.delete(streamSessionId);
|
||||
const streamQuery = msg;
|
||||
_touchStreamActivity(streamSessionId);
|
||||
|
||||
@@ -1373,6 +1475,7 @@ import {
|
||||
let _thinkOpen = false;
|
||||
let holder = null;
|
||||
let finalMeta = null;
|
||||
let _canonicalTerminalSaved = false;
|
||||
let spinner = null;
|
||||
let timedOut = false;
|
||||
let processingProbeTimer = null;
|
||||
@@ -1742,8 +1845,26 @@ import {
|
||||
}
|
||||
|
||||
|
||||
// Superseded during preflight (uploads, document saves): a newer send
|
||||
// owns the session. Bailing here — before registration and before the
|
||||
// POST — keeps this stale send from overwriting the replacement's
|
||||
// stream entry or reaching the server last, where agent_runs.start
|
||||
// would cancel the newer run in favor of this old one.
|
||||
if (_streamGenerations.get(streamSessionId) !== streamGeneration) {
|
||||
// The optimistic user bubble is already in the DOM looking sent, but
|
||||
// this message never reaches the server. Say so instead of leaving a
|
||||
// ghost that vanishes on refresh.
|
||||
if (_userMsgEl && _userMsgEl.parentNode) {
|
||||
const _notSentNote = document.createElement('div');
|
||||
_notSentNote.style.cssText = 'color: var(--color-error); font-style: italic; font-size: 0.85em; padding: 2px 0;';
|
||||
_notSentNote.textContent = '[Not sent — superseded by a newer message]';
|
||||
_userMsgEl.appendChild(_notSentNote);
|
||||
}
|
||||
return;
|
||||
}
|
||||
abortCtrl = new AbortController();
|
||||
abortCtrl._reason = '';
|
||||
_sendState.abortCtrl = abortCtrl;
|
||||
currentAbort = abortCtrl;
|
||||
|
||||
const _tState = Storage.loadToggleState();
|
||||
@@ -1755,15 +1876,28 @@ import {
|
||||
if (!abortCtrl.signal.aborted) {
|
||||
timedOut = true;
|
||||
abortCtrl._reason = 'timeout';
|
||||
if (_streamGenerations.get(streamSessionId) !== streamGeneration) {
|
||||
// Superseded send: the session's run id and Stop queue belong to
|
||||
// the replacement now. Just kill this hung POST.
|
||||
abortCtrl.abort();
|
||||
return;
|
||||
}
|
||||
let abortNow = true;
|
||||
try {
|
||||
if (streamSessionId) {
|
||||
fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {});
|
||||
}
|
||||
abortNow = _streamRunIds.has(streamSessionId)
|
||||
? _stopExactRun(streamSessionId)
|
||||
: _stopExactRun(streamSessionId, abortCtrl);
|
||||
} catch (_) {}
|
||||
abortCtrl.abort();
|
||||
if (abortNow) {
|
||||
abortCtrl.abort();
|
||||
} else {
|
||||
// The Stop is queued on the run-id header, but a request this
|
||||
// stalled may never send one. Hard-abort after a short grace so
|
||||
// the timeout still guarantees cancellation.
|
||||
setTimeout(() => {
|
||||
if (!abortCtrl.signal.aborted) abortCtrl.abort();
|
||||
}, RUN_ID_ABORT_GRACE_MS);
|
||||
}
|
||||
}
|
||||
}, timeoutMs);
|
||||
clearResponseTimeout = () => {
|
||||
@@ -1912,6 +2046,8 @@ import {
|
||||
enableResearchBtn();
|
||||
return;
|
||||
}
|
||||
const streamRunId = res.headers.get('X-Odysseus-Run-Id') || '';
|
||||
if (streamRunId) _rememberStreamRunId(streamSessionId, streamRunId, streamGeneration);
|
||||
|
||||
// Mark the chat log busy while streaming so screen readers wait for the
|
||||
// settled response instead of announcing every token. Cleared in finally.
|
||||
@@ -1986,9 +2122,17 @@ import {
|
||||
const newRole = document.createElement('div');
|
||||
newRole.className = 'role';
|
||||
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
|
||||
const requested = holder?._requestedModel || metaS?.model || modelName;
|
||||
const actual = holder?._actualModel || requested;
|
||||
newRole.textContent = _modelRouteLabel(requested, actual) || '';
|
||||
inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName);
|
||||
const requested = newWrap._requestedModel;
|
||||
const actual = newWrap._actualModel;
|
||||
newRole.textContent = _modelRouteLabel(
|
||||
requested,
|
||||
actual,
|
||||
newWrap._requestedEndpointLabel,
|
||||
newWrap._actualEndpointLabel,
|
||||
newWrap._requestedEndpointId,
|
||||
newWrap._actualEndpointId,
|
||||
) || '';
|
||||
_applyModelColor(newRole, actual);
|
||||
newWrap.appendChild(newRole);
|
||||
const newBody = document.createElement('div');
|
||||
@@ -2514,6 +2658,7 @@ import {
|
||||
|
||||
let _nextIsError = false;
|
||||
let _streamSawDone = false;
|
||||
let _streamTerminalError = null;
|
||||
let _firstVisibleOutputSeen = false;
|
||||
const markFirstVisibleOutput = () => {
|
||||
if (_firstVisibleOutputSeen) return;
|
||||
@@ -2638,10 +2783,9 @@ import {
|
||||
// Handle SSE error events (e.g. HTTP 404 from provider)
|
||||
if (_nextIsError || json.status >= 400) {
|
||||
_nextIsError = false;
|
||||
const errMsg = json.text || json.error?.message || `Error ${json.status || 'unknown'}`;
|
||||
console.error('Stream error:', errMsg);
|
||||
_streamTerminalError = createTerminalStreamError(json);
|
||||
console.error('Stream error:', _streamTerminalError.message);
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
typewriterInto(roundHolder.querySelector('.body'), errMsg);
|
||||
break;
|
||||
}
|
||||
if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||
@@ -3040,18 +3184,6 @@ import {
|
||||
6000
|
||||
);
|
||||
continue;
|
||||
} else if (json.type === 'model_fallback') {
|
||||
// Model went offline — switched to fallback
|
||||
var _fbData = json.data || {};
|
||||
uiModule.showToast(
|
||||
`Model ${_fbData.old_model || '?'} offline — switched to ${_fbData.new_model || '?'}`,
|
||||
5000
|
||||
);
|
||||
// Update the model picker to reflect the new model
|
||||
if (sessionModule && sessionModule.updateModelPicker) {
|
||||
sessionModule.updateModelPicker();
|
||||
}
|
||||
continue;
|
||||
} else if (json.type === 'model_info') {
|
||||
// Update role label with model name as soon as we know it
|
||||
if (!_isBg && holder) {
|
||||
@@ -3059,6 +3191,10 @@ import {
|
||||
if (roleEl) {
|
||||
holder._requestedModel = json.requested_model || json.model || holder._requestedModel;
|
||||
holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
|
||||
holder._requestedEndpointId = json.requested_endpoint_id || json.endpoint_id || holder._requestedEndpointId || null;
|
||||
holder._requestedEndpointLabel = json.requested_endpoint_label || json.endpoint_label || holder._requestedEndpointLabel || 'Selected route';
|
||||
holder._actualEndpointId = json.endpoint_id || holder._actualEndpointId || holder._requestedEndpointId;
|
||||
holder._actualEndpointLabel = json.endpoint_label || holder._actualEndpointLabel || holder._requestedEndpointLabel;
|
||||
if (json.suffix) holder._roleSuffix = json.suffix;
|
||||
// Prepend character name if sent by server or set locally
|
||||
var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
|
||||
@@ -3066,6 +3202,10 @@ import {
|
||||
_setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, {
|
||||
suffix: holder._roleSuffix,
|
||||
characterName: holder._characterName,
|
||||
requestedEndpointId: holder._requestedEndpointId,
|
||||
requestedEndpointLabel: holder._requestedEndpointLabel,
|
||||
actualEndpointId: holder._actualEndpointId,
|
||||
actualEndpointLabel: holder._actualEndpointLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3076,9 +3216,10 @@ import {
|
||||
if (!_isBg) {
|
||||
var _selM = _shortModel(json.selected_model || '');
|
||||
var _ansM = _shortModel(json.answered_by || '');
|
||||
uiModule.showToast('⚠ ' + _selM + ' failed — answered by ' + _ansM, 6000);
|
||||
if (holder) {
|
||||
var _rEl = holder.querySelector('.role');
|
||||
uiModule.showToast('Fallback: ' + _selM + ' failed — answered by ' + _ansM, 6000);
|
||||
var _fallbackHolder = applyModelRouteEventState(json, holder, roundHolder, modelName);
|
||||
if (_fallbackHolder) {
|
||||
var _rEl = _fallbackHolder.querySelector('.role');
|
||||
if (_rEl) {
|
||||
var _tsS = _rEl.querySelector('.role-timestamp');
|
||||
_rEl.textContent = _ansM + ' (fallback) ';
|
||||
@@ -3086,13 +3227,14 @@ import {
|
||||
(json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || '');
|
||||
_applyModelColor(_rEl, json.answered_by);
|
||||
if (_tsS) _rEl.appendChild(_tsS);
|
||||
holder._requestedModel = json.selected_model || holder._requestedModel || modelName;
|
||||
const _hasResolvedActual = holder._actualModel && !_sameModelName(holder._actualModel, holder._requestedModel);
|
||||
holder._actualModel = _hasResolvedActual ? holder._actualModel : (json.answered_by || holder._actualModel || holder._requestedModel);
|
||||
_setRoleModelLabel(_rEl, holder._requestedModel, holder._actualModel, {
|
||||
suffix: holder._roleSuffix,
|
||||
characterName: holder._characterName,
|
||||
_setRoleModelLabel(_rEl, _fallbackHolder._requestedModel, _fallbackHolder._actualModel, {
|
||||
suffix: _fallbackHolder._roleSuffix,
|
||||
characterName: _fallbackHolder._characterName,
|
||||
reason: json.reason,
|
||||
requestedEndpointId: _fallbackHolder._requestedEndpointId,
|
||||
requestedEndpointLabel: _fallbackHolder._requestedEndpointLabel,
|
||||
actualEndpointId: _fallbackHolder._actualEndpointId,
|
||||
actualEndpointLabel: _fallbackHolder._actualEndpointLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3136,12 +3278,15 @@ import {
|
||||
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
||||
}
|
||||
} else if (json.type === 'model_actual') {
|
||||
if (!_isBg && holder) {
|
||||
holder._requestedModel = json.requested_model || holder._requestedModel || modelName;
|
||||
holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
|
||||
_setRoleModelLabel(holder.querySelector('.role'), holder._requestedModel, holder._actualModel, {
|
||||
suffix: holder._roleSuffix,
|
||||
characterName: holder._characterName,
|
||||
if (!_isBg) {
|
||||
var _modelHolder = applyModelRouteEventState(json, holder, roundHolder, modelName);
|
||||
if (_modelHolder) _setRoleModelLabel(_modelHolder.querySelector('.role'), _modelHolder._requestedModel, _modelHolder._actualModel, {
|
||||
suffix: _modelHolder._roleSuffix,
|
||||
characterName: _modelHolder._characterName,
|
||||
requestedEndpointId: _modelHolder._requestedEndpointId,
|
||||
requestedEndpointLabel: _modelHolder._requestedEndpointLabel,
|
||||
actualEndpointId: _modelHolder._actualEndpointId,
|
||||
actualEndpointLabel: _modelHolder._actualEndpointLabel,
|
||||
});
|
||||
}
|
||||
} else if (json.type === 'attachments') {
|
||||
@@ -3227,15 +3372,60 @@ import {
|
||||
const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : '';
|
||||
uiModule.showToast(`Context trimmed for this model${detail}`);
|
||||
}
|
||||
} else if (json.type === 'agent_terminal' || json.type === 'chat_terminal') {
|
||||
// The backend persisted canonical partial output, sanitized
|
||||
// failure metadata, and actual-route provenance before this
|
||||
// event. The terminal catch below reloads that exact record.
|
||||
_canonicalTerminalSaved = true;
|
||||
_terminalSavedStreams.add(streamSessionId);
|
||||
const priorMetrics = metrics;
|
||||
metrics = json.data || metrics;
|
||||
if (metrics && streamRunId) {
|
||||
metrics._costRecordId = _metricsCostRecordId(streamRunId, json);
|
||||
}
|
||||
// Direct Chat may have emitted provider usage before its
|
||||
// terminal event. Carry that already-recorded state onto the
|
||||
// canonical terminal metadata instead of billing it twice.
|
||||
if (priorMetrics && priorMetrics._costRecorded && metrics) {
|
||||
metrics._costRecorded = true;
|
||||
}
|
||||
if (_isBg) {
|
||||
var bgTerminal = _backgroundStreams.get(streamSessionId);
|
||||
if (bgTerminal) {
|
||||
if (
|
||||
bgTerminal.metrics
|
||||
&& bgTerminal.metrics._costRecorded
|
||||
&& metrics
|
||||
) {
|
||||
metrics._costRecorded = true;
|
||||
}
|
||||
bgTerminal.metrics = metrics;
|
||||
bgTerminal.status = 'completed';
|
||||
if (metrics) {
|
||||
chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (holder && metrics) {
|
||||
applyModelMetricsState(metrics, holder, roundHolder, modelName);
|
||||
const terminalMetricsTarget = _metricsTargetForTurn();
|
||||
if (terminalMetricsTarget) displayMetrics(terminalMetricsTarget, metrics);
|
||||
}
|
||||
} else if (json.type === 'metrics') {
|
||||
metrics = json.data;
|
||||
if (metrics && streamRunId) {
|
||||
metrics._costRecordId = _metricsCostRecordId(streamRunId, json);
|
||||
}
|
||||
if (!_isBg && holder && metrics) {
|
||||
holder._requestedModel = metrics.requested_model || holder._requestedModel || modelName;
|
||||
holder._actualModel = metrics.model || holder._actualModel || holder._requestedModel;
|
||||
applyModelMetricsState(metrics, holder, roundHolder, modelName);
|
||||
}
|
||||
if (_isBg) {
|
||||
var bgM = _backgroundStreams.get(streamSessionId);
|
||||
if (bgM) bgM.metrics = json.data;
|
||||
if (bgM) {
|
||||
bgM.metrics = json.data;
|
||||
chatRenderer.recordSessionMetricsCost(bgM.metrics, streamSessionId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (metrics) {
|
||||
@@ -3616,9 +3806,17 @@ import {
|
||||
const newRole = document.createElement('div');
|
||||
newRole.className = 'role';
|
||||
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
|
||||
const _roundRequested = holder?._requestedModel || metaS?.model;
|
||||
const _roundActual = holder?._actualModel || _roundRequested;
|
||||
newRole.textContent = _modelRouteLabel(_roundRequested, _roundActual) || '';
|
||||
inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName);
|
||||
const _roundRequested = newWrap._requestedModel;
|
||||
const _roundActual = newWrap._actualModel;
|
||||
newRole.textContent = _modelRouteLabel(
|
||||
_roundRequested,
|
||||
_roundActual,
|
||||
newWrap._requestedEndpointLabel,
|
||||
newWrap._actualEndpointLabel,
|
||||
newWrap._requestedEndpointId,
|
||||
newWrap._actualEndpointId,
|
||||
) || '';
|
||||
_applyModelColor(newRole, _roundActual);
|
||||
newWrap.appendChild(newRole);
|
||||
const newBody = document.createElement('div');
|
||||
@@ -3725,8 +3923,21 @@ import {
|
||||
}
|
||||
}
|
||||
|
||||
if (_streamTerminalError) {
|
||||
throw _streamTerminalError;
|
||||
}
|
||||
if (!_streamSawDone) {
|
||||
throw new Error('Stream closed before completion');
|
||||
if (!_canonicalTerminalSaved) {
|
||||
throw new Error('Stream closed before completion');
|
||||
}
|
||||
// The backend persisted a canonical terminal record (partial output +
|
||||
// failure metadata) before the connection died. Route through the
|
||||
// terminal-error path so that record is reloaded; falling through to
|
||||
// the success renderer would present the partial output as a clean
|
||||
// completion.
|
||||
throw createTerminalStreamError({
|
||||
text: 'Stream closed after canonical terminal event',
|
||||
});
|
||||
}
|
||||
|
||||
// The final foreground render below is authoritative. Cancel any delayed
|
||||
@@ -3746,15 +3957,25 @@ import {
|
||||
const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
|
||||
if (!_isBgFinal) {
|
||||
finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
||||
const _finalActualModel = metrics?.model || holder._actualModel || finalMeta?.model;
|
||||
const _finalRequestedModel = metrics?.requested_model || holder._requestedModel || finalMeta?.model || _finalActualModel;
|
||||
const _finalModelHolder = applyModelMetricsState(
|
||||
metrics,
|
||||
holder,
|
||||
roundHolder,
|
||||
finalMeta?.model || modelName,
|
||||
) || holder;
|
||||
const _finalActualModel = _finalModelHolder._actualModel || finalMeta?.model;
|
||||
const _finalRequestedModel = _finalModelHolder._requestedModel || finalMeta?.model || _finalActualModel;
|
||||
// Prepend character name if set
|
||||
var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
|
||||
const roleEl = holder.querySelector('.role');
|
||||
const roleEl = _finalModelHolder.querySelector('.role');
|
||||
if (roleEl) {
|
||||
_setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, {
|
||||
suffix: holder._roleSuffix,
|
||||
characterName: _charNameFinal || holder._characterName,
|
||||
suffix: _finalModelHolder._roleSuffix,
|
||||
characterName: _charNameFinal || _finalModelHolder._characterName,
|
||||
requestedEndpointId: _finalModelHolder._requestedEndpointId,
|
||||
requestedEndpointLabel: _finalModelHolder._requestedEndpointLabel,
|
||||
actualEndpointId: _finalModelHolder._actualEndpointId,
|
||||
actualEndpointLabel: _finalModelHolder._actualEndpointLabel,
|
||||
});
|
||||
}
|
||||
holder.dataset.raw = accumulated;
|
||||
@@ -4013,6 +4234,21 @@ import {
|
||||
} // end if (!_isBgFinal)
|
||||
|
||||
} catch (err) {
|
||||
// If a Stop or timeout was waiting for an identity header and the POST
|
||||
// failed before producing one, keep this on the cancellation path. There
|
||||
// is no safe headerless server cancel to send, but it must not be turned
|
||||
// into an automatic recovery attempt either. Only this send's own
|
||||
// queued Stop counts; a replacement's queued Stop is not ours to spend.
|
||||
const _pendingCatchKey = streamSessionId + ':' + streamGeneration;
|
||||
if (
|
||||
_pendingRunStops.has(_pendingCatchKey)
|
||||
&& abortCtrl
|
||||
&& !abortCtrl.signal.aborted
|
||||
) {
|
||||
_pendingRunStops.delete(_pendingCatchKey);
|
||||
abortCtrl._reason = 'user-stop';
|
||||
abortCtrl.abort();
|
||||
}
|
||||
// Check if this stream was running in background — needed before any
|
||||
// stop-state write, so an errored background stream can't clobber the
|
||||
// foreground session's text.
|
||||
@@ -4021,6 +4257,18 @@ import {
|
||||
_closeOpenThinkingMarkup(_isBgCatch);
|
||||
if (_isBgCatch) {
|
||||
_cancelLiveThinkingWork();
|
||||
|
||||
// A canonical terminal event may have been persisted immediately
|
||||
// before the stream moved into the background. Preserve that terminal
|
||||
// state instead of allowing the catch path to turn it back into a
|
||||
// running/error stream.
|
||||
const bgTerminal = _backgroundStreams.get(streamSessionId);
|
||||
if (bgTerminal && _terminalSavedStreams.has(streamSessionId)) {
|
||||
bgTerminal.status = 'completed';
|
||||
if (sessionModule && sessionModule.clearStreaming) {
|
||||
sessionModule.clearStreaming(streamSessionId);
|
||||
}
|
||||
}
|
||||
} else if (accumulated) {
|
||||
_catchTerminalView = _finalizeInterruptedView();
|
||||
} else {
|
||||
@@ -4039,7 +4287,10 @@ import {
|
||||
// Error happened while backgrounded — update map, don't touch DOM
|
||||
console.error('Background stream error:', err);
|
||||
var bgErr = _backgroundStreams.get(streamSessionId);
|
||||
if (bgErr && bgErr.status === 'completed') {
|
||||
if (bgErr && (
|
||||
bgErr.status === 'completed' || _terminalSavedStreams.has(streamSessionId)
|
||||
)) {
|
||||
bgErr.status = 'completed';
|
||||
// [DONE] was already processed — this error is benign (e.g. reader.read() after close)
|
||||
// Don't override the completed status; just ensure the completed dot stays
|
||||
if (sessionModule && sessionModule.clearStreaming) {
|
||||
@@ -4191,8 +4442,36 @@ import {
|
||||
// cap. Only auto-recover from connection-class failures; deterministic
|
||||
// errors (unsupported tools, 4xx/5xx, parse failures) surface right away
|
||||
// instead of burning the nudge budget on a guaranteed-to-fail retry.
|
||||
if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(_catchViewHolder, accumulated, streamSessionId))) {
|
||||
const errorHolder = _catchViewHolder?.querySelector('.body') || document.querySelector('.msg-ai:last-of-type .body');
|
||||
if (!(isRecoverableStreamError(err) && _tryAutoRecover(_catchViewHolder, accumulated, streamSessionId))) {
|
||||
if (err.terminalStreamError) {
|
||||
if (_canonicalTerminalSaved || accumulated.trim()) {
|
||||
// Let this stream's finally block clear foreground state before
|
||||
// reselecting; otherwise selectSession would detach the already
|
||||
// terminal reader and leave a stale background-stream marker.
|
||||
setTimeout(async () => {
|
||||
if (sessionModule.getCurrentSessionId() === streamSessionId) {
|
||||
await sessionModule.selectSession(streamSessionId, { showLoading: false });
|
||||
} else {
|
||||
await sessionModule.loadSessions();
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
const terminalBody =
|
||||
_catchViewHolder?.querySelector('.body')
|
||||
|| roundHolder?.querySelector('.body')
|
||||
|| document.querySelector('.msg-ai:last-of-type .body');
|
||||
if (terminalBody) {
|
||||
const terminalNote = document.createElement('div');
|
||||
terminalNote.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
|
||||
terminalNote.textContent = `[Error: ${err.message}]`;
|
||||
terminalBody.appendChild(terminalNote);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const errorHolder =
|
||||
_catchViewHolder?.querySelector('.body')
|
||||
|| document.querySelector('.msg-ai:last-of-type .body');
|
||||
if (errorHolder) {
|
||||
let errMsg = `Error: ${err.message}`;
|
||||
// Add hint for tool-call errors
|
||||
@@ -4209,23 +4488,52 @@ import {
|
||||
clearResponseTimeout();
|
||||
clearProcessingProbe();
|
||||
clearFirstTokenWaitTimers();
|
||||
_activeStreams.delete(streamSessionId);
|
||||
if (_streamSessionId === streamSessionId) _streamSessionId = null;
|
||||
_syncForegroundStreamGlobals();
|
||||
// A replacement send bumps the session's generation the moment it
|
||||
// starts, before it registers or reaches the server, so cleanup rights
|
||||
// are decided by generation: a superseded send may remove only what it
|
||||
// itself owns (its stream registration by controller identity, its own
|
||||
// generation's queued Stop) and must leave session-level state — the
|
||||
// reader session id, research marker, UI — to the replacement.
|
||||
const _ownsStreamState =
|
||||
_streamGenerations.get(streamSessionId) === streamGeneration;
|
||||
const _finallyRegistered = _activeStreams.get(streamSessionId);
|
||||
if (!_finallyRegistered || _finallyRegistered.abortCtrl === abortCtrl) {
|
||||
_activeStreams.delete(streamSessionId);
|
||||
}
|
||||
_pendingRunStops.delete(streamSessionId + ':' + streamGeneration);
|
||||
if (_ownsStreamState) {
|
||||
if (_streamSessionId === streamSessionId) _streamSessionId = null;
|
||||
if (_sendStates.get(streamSessionId) === _sendState) {
|
||||
_sendStates.delete(streamSessionId);
|
||||
}
|
||||
// Superseded sends must not resync: with the replacement not yet
|
||||
// registered, a stale sync would set isStreaming false and drop
|
||||
// currentAbort while _sendInFlight is already false, reopening the
|
||||
// send gate mid-preflight. The replacement syncs when it registers
|
||||
// or finishes.
|
||||
_syncForegroundStreamGlobals();
|
||||
}
|
||||
// Streaming done — let screen readers announce the settled response.
|
||||
const _chatLogDone = document.getElementById('chat-history');
|
||||
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
|
||||
// Always clean up research tracking regardless of background state
|
||||
_researchingStreamIds.delete(streamSessionId);
|
||||
if (_ownsStreamState) {
|
||||
const _chatLogDone = document.getElementById('chat-history');
|
||||
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
// Research markers gate /api/research/cancel in the Stop handler, so a
|
||||
// superseded send must not strip a replacement research run's marker.
|
||||
if (_ownsStreamState) _researchingStreamIds.delete(streamSessionId);
|
||||
if (_researchingStreamIds.size === 0) {
|
||||
var _rToggleCleanup = document.getElementById('research-toggle-btn');
|
||||
if (_rToggleCleanup) _rToggleCleanup.classList.remove('research-running');
|
||||
}
|
||||
|
||||
// Only reset UI state if still on the stream's session and was never backgrounded
|
||||
// Only reset UI state if still on the stream's session, never
|
||||
// backgrounded, and no replacement stream owns the session now — the
|
||||
// replacement disabled the composer for its own send, so re-enabling
|
||||
// it here would hand input back mid-stream.
|
||||
const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
|
||||
if (_ownsStreamState) _terminalSavedStreams.delete(streamSessionId);
|
||||
|
||||
if (!_isBgFinally) {
|
||||
if (!_isBgFinally && _ownsStreamState) {
|
||||
// Reset button to idle state
|
||||
updateSubmitButton('idle', submitBtn);
|
||||
|
||||
@@ -4320,69 +4628,64 @@ import {
|
||||
// the server run — otherwise closing the tab would kill the background task,
|
||||
// defeating the whole point. Only the Stop button cancels the server run.
|
||||
export function abortCurrentRequest(stopServer = false) {
|
||||
const _sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId())
|
||||
|| _streamSessionId
|
||||
|| (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
|
||||
// The CURRENT send's controller comes from its send state, installed at
|
||||
// send commit — never borrowed from the stream registry, which during the
|
||||
// replacement's preflight still holds the superseded send's entry.
|
||||
// Aborting that older controller here would sever the only identity
|
||||
// channel able to name the old run. A send committed but pre-POST has a
|
||||
// null controller: the Stop queues and there is nothing to abort yet.
|
||||
const _sendStateNow = _sid ? _sendStates.get(_sid) : null;
|
||||
const active = _getForegroundStreamState();
|
||||
const abortCtrl = active ? active.abortCtrl : currentAbort;
|
||||
if (abortCtrl) {
|
||||
abortCtrl.abort();
|
||||
// Don't set to null here - let catch block handle it
|
||||
}
|
||||
const abortCtrl = _sendStateNow
|
||||
? _sendStateNow.abortCtrl
|
||||
: (active ? active.abortCtrl : currentAbort);
|
||||
let abortNow = true;
|
||||
if (stopServer) {
|
||||
try {
|
||||
const _sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId())
|
||||
|| _streamSessionId
|
||||
|| (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
|
||||
if (_sid) {
|
||||
fetch(`/api/chat/stop/${encodeURIComponent(_sid)}`, { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||||
// Before response headers arrive there is no safe server-side stop
|
||||
// identity yet. Keep the POST alive just long enough to receive that
|
||||
// opaque id, then _rememberStreamRunId sends the exact Stop and aborts
|
||||
// this reader. Never fall back to a headerless session-wide cancel.
|
||||
abortNow = _stopExactRun(_sid, abortCtrl);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (abortCtrl && abortNow) {
|
||||
abortCtrl.abort();
|
||||
// Don't set to null here - let catch block handle it
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stall watchdog ──────────────────────────────────────────────
|
||||
// Auto-recover a turn whose stream died (connection drop) or went silent:
|
||||
// preserve the partial, then re-submit a completion handshake by reusing the
|
||||
// existing continue/resume path. Returns false at the cap so the caller can
|
||||
// surface the failure instead of nudging forever.
|
||||
// Auto-recover a turn whose browser stream died by reconnecting to the exact
|
||||
// detached server run. Returns false at the cap so the caller can surface
|
||||
// the failure instead of retrying forever.
|
||||
// Only auto-recover from connection-class failures (the genuine "silently
|
||||
// died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON
|
||||
// parse failures — will fail identically on retry, so surfacing them
|
||||
// immediately is both more honest and avoids wasting the nudge budget.
|
||||
function _isRecoverableStreamErr(err) {
|
||||
if (!err) return false;
|
||||
if (err.name === 'TypeError') return true; // fetch/reader network failure
|
||||
const m = (err.message || '').toLowerCase();
|
||||
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(m)) return false;
|
||||
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(m);
|
||||
}
|
||||
|
||||
function _tryAutoRecover(holder, accumulated, sessionId) {
|
||||
if (_autoNudges >= _AUTO_NUDGE_CAP) return false;
|
||||
_autoNudges++;
|
||||
if (holder && accumulated) {
|
||||
holder.dataset.raw = accumulated;
|
||||
}
|
||||
_pendingContinue = holder || null; // merge the continuation into the same bubble
|
||||
_hideUserBubble = true; // no user bubble for the handshake
|
||||
_autoContinuePending = true; // don't reset the counter on this submit
|
||||
const _abandon = () => { // clear the pending flags so they can't
|
||||
_pendingContinue = null; // leak into whatever chat is now open
|
||||
_hideUserBubble = false;
|
||||
_autoContinuePending = false;
|
||||
};
|
||||
// Defer so the stream's finally resets state first — otherwise the send
|
||||
// button is still in "stop" mode and clicking it would toggle, not send.
|
||||
setTimeout(() => {
|
||||
// The server run is detached and keeps its exact pinned model/tool state.
|
||||
// Reconnect to that run instead of submitting a new user turn, which would
|
||||
// cancel it, retry the selected model, and risk duplicating side effects.
|
||||
setTimeout(async () => {
|
||||
// The stream that died may not be the chat the user is now looking at —
|
||||
// never inject the recovery handshake into the wrong conversation.
|
||||
if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) { _abandon(); return; }
|
||||
const msgInput = uiModule.el('message');
|
||||
const sb = document.querySelector('.send-btn');
|
||||
if (!msgInput || !sb) { _abandon(); return; }
|
||||
const tail = (accumulated || '').slice(-400);
|
||||
msgInput.value = tail
|
||||
? `The stream dropped before you finished. It ended with:\n\n${tail}\n\nIf the task is fully complete, reply with just: DONE. Otherwise continue exactly where you left off and finish it — do not repeat what you already wrote.`
|
||||
: `The stream dropped before you produced anything. If the task is already done, reply with just: DONE. Otherwise complete it now.`;
|
||||
sb.click();
|
||||
// never attach the recovery reader to the wrong conversation.
|
||||
if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) return;
|
||||
const resumed = await resumeStream(sessionId, holder || null);
|
||||
if (!resumed && holder && holder.isConnected) {
|
||||
const body = holder.querySelector('.body');
|
||||
if (body) typewriterInto(body, 'Connection lost. The existing run could not be resumed.');
|
||||
}
|
||||
}, 200);
|
||||
return true;
|
||||
}
|
||||
@@ -4545,9 +4848,13 @@ import {
|
||||
// view must stop all delayed rendering immediately. The reader loop may not
|
||||
// receive another SSE line for an arbitrary amount of time.
|
||||
if (active.cancelViewWork) active.cancelViewWork();
|
||||
// Store background stream state
|
||||
|
||||
const terminalSaved = _terminalSavedStreams.has(sessionId);
|
||||
// Store background stream state. A canonical terminal event can precede
|
||||
// its SSE error event; preserve completion if the user switches sessions
|
||||
// during that gap instead of creating a fresh running/error marker.
|
||||
_backgroundStreams.set(sessionId, {
|
||||
status: 'running',
|
||||
status: terminalSaved ? 'completed' : 'running',
|
||||
accumulated: currentAccumulated,
|
||||
sourcesHtml: '',
|
||||
findingsData: null,
|
||||
@@ -4556,8 +4863,10 @@ import {
|
||||
metrics: null,
|
||||
});
|
||||
// Mark session with pulsing dot in sidebar
|
||||
if (sessionModule && sessionModule.markStreaming) {
|
||||
if (!terminalSaved && sessionModule && sessionModule.markStreaming) {
|
||||
sessionModule.markStreaming(sessionId);
|
||||
} else if (terminalSaved && sessionModule && sessionModule.clearStreaming) {
|
||||
sessionModule.clearStreaming(sessionId);
|
||||
}
|
||||
// Clear local state WITHOUT aborting the fetch
|
||||
if (currentAbort === active.abortCtrl) currentAbort = null;
|
||||
@@ -4584,7 +4893,7 @@ import {
|
||||
* reloaded from the DB so its full render stays faithful. Returns true if it
|
||||
* attached, false to let the caller fall back to spinner+poll.
|
||||
*/
|
||||
export async function resumeStream(sessionId) {
|
||||
export async function resumeStream(sessionId, replaceHolder = null) {
|
||||
if (!sessionId) return false;
|
||||
if (hasActiveStream(sessionId)) return false;
|
||||
|
||||
@@ -4595,9 +4904,12 @@ import {
|
||||
return false;
|
||||
}
|
||||
if (!res.ok || !res.body) return false;
|
||||
const resumeRunId = res.headers.get('X-Odysseus-Run-Id') || '';
|
||||
if (resumeRunId) _streamRunIds.set(sessionId, resumeRunId);
|
||||
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return false;
|
||||
if (replaceHolder && replaceHolder.parentNode) replaceHolder.remove();
|
||||
|
||||
// Block duplicate re-attach attempts while this reader is live. A dedicated
|
||||
// set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
|
||||
@@ -4612,6 +4924,8 @@ import {
|
||||
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) +
|
||||
' <span class="role-timestamp">' + roleTs + '</span></div>' +
|
||||
'<div class="body"><div class="stream-content"></div></div>';
|
||||
holder._requestedModel = meta && meta.model;
|
||||
holder._actualModel = holder._requestedModel;
|
||||
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
|
||||
const contentDiv = holder.querySelector('.stream-content');
|
||||
box.appendChild(holder);
|
||||
@@ -4629,6 +4943,8 @@ import {
|
||||
let gotDelta = false;
|
||||
let leftSession = false;
|
||||
let metricsData = null;
|
||||
let replayError = null;
|
||||
let canonicalTerminalSeen = false;
|
||||
// "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
|
||||
// full canonical render, which is rebuilt from the saved DB record on reload.
|
||||
// Plain text replies can be finalized in place without a reload.
|
||||
@@ -4665,6 +4981,8 @@ import {
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop();
|
||||
for (const part of parts) {
|
||||
const eventIsError = part.split('\n').some(l => l.trim() === 'event: error');
|
||||
if (eventIsError) rich = true;
|
||||
const line = part.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!line) continue;
|
||||
const payload = line.slice(6);
|
||||
@@ -4674,7 +4992,9 @@ import {
|
||||
}
|
||||
let json;
|
||||
try { json = JSON.parse(payload); } catch (_) { continue; }
|
||||
if (json.delta) {
|
||||
if (eventIsError) {
|
||||
replayError = createTerminalStreamError(json);
|
||||
} else if (json.delta) {
|
||||
roundText += json.delta;
|
||||
if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
|
||||
docFenceOpened = true;
|
||||
@@ -4690,6 +5010,64 @@ import {
|
||||
if (documentModule) documentModule.streamDocDelta(json.content || json.delta || '');
|
||||
} else if (json.type === 'metrics') {
|
||||
metricsData = json.data || metricsData;
|
||||
if (metricsData && resumeRunId) {
|
||||
metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);
|
||||
}
|
||||
if (metricsData) {
|
||||
chatRenderer.recordSessionMetricsCost(metricsData, sessionId);
|
||||
}
|
||||
} else if (json.type === 'fallback') {
|
||||
// Replay can attach after the selected route has already failed.
|
||||
// Reflect the fallback immediately, then reload the canonical
|
||||
// multi-round record when the detached run completes.
|
||||
rich = true;
|
||||
const fallbackHolder = applyModelRouteEventState(json, holder, null, meta && meta.model);
|
||||
if (fallbackHolder) {
|
||||
_setRoleModelLabel(
|
||||
fallbackHolder.querySelector('.role'),
|
||||
fallbackHolder._requestedModel,
|
||||
fallbackHolder._actualModel,
|
||||
{
|
||||
reason: json.reason,
|
||||
requestedEndpointId: fallbackHolder._requestedEndpointId,
|
||||
requestedEndpointLabel: fallbackHolder._requestedEndpointLabel,
|
||||
actualEndpointId: fallbackHolder._actualEndpointId,
|
||||
actualEndpointLabel: fallbackHolder._actualEndpointLabel,
|
||||
},
|
||||
);
|
||||
}
|
||||
uiModule.showToast(
|
||||
'Fallback: ' + _shortModel(json.selected_model || '') + ' failed — answered by ' +
|
||||
_shortModel(json.answered_by || ''),
|
||||
6000,
|
||||
);
|
||||
} else if (json.type === 'model_actual') {
|
||||
rich = true;
|
||||
const modelHolder = applyModelRouteEventState(json, holder, null, meta && meta.model);
|
||||
if (modelHolder) {
|
||||
_setRoleModelLabel(
|
||||
modelHolder.querySelector('.role'),
|
||||
modelHolder._requestedModel,
|
||||
modelHolder._actualModel,
|
||||
{
|
||||
requestedEndpointId: modelHolder._requestedEndpointId,
|
||||
requestedEndpointLabel: modelHolder._requestedEndpointLabel,
|
||||
actualEndpointId: modelHolder._actualEndpointId,
|
||||
actualEndpointLabel: modelHolder._actualEndpointLabel,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if (json.type === 'agent_terminal' || json.type === 'chat_terminal') {
|
||||
// The server has already persisted canonical partial content plus
|
||||
// a sanitized failure note and actual route provenance. Do not
|
||||
// finalize replayed deltas as a successful local-only answer.
|
||||
rich = true;
|
||||
canonicalTerminalSeen = true;
|
||||
metricsData = json.data || metricsData;
|
||||
if (metricsData && resumeRunId) {
|
||||
metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);
|
||||
}
|
||||
if (metricsData) displayMetrics(holder, metricsData);
|
||||
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
|
||||
json.type === 'tool_progress' || json.type === 'agent_step' ||
|
||||
json.type === 'web_sources' || json.type === 'rag_sources' ||
|
||||
@@ -4700,7 +5078,8 @@ import {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Network drop or parse failure: fall through to the reload below.
|
||||
// Network drop or parse failure: fall through to the canonical reload.
|
||||
rich = true;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
@@ -4710,6 +5089,18 @@ import {
|
||||
const onThisSession = sessionModule.getCurrentSessionId &&
|
||||
sessionModule.getCurrentSessionId() === sessionId;
|
||||
|
||||
// A failure before substantive output has no persisted assistant record to
|
||||
// recover through a canonical reload. Keep its sanitized provider/request
|
||||
// error visible in the replay holder instead of deleting the only evidence.
|
||||
if (onThisSession && replayError && !canonicalTerminalSeen) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
|
||||
errorDiv.textContent = `[Error: ${replayError.message}]`;
|
||||
contentDiv.appendChild(errorDiv);
|
||||
uiModule.scrollHistory();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Plain text reply: finalize in place. Replace the live bubble with a
|
||||
// canonical single message (markdown + footer actions + metrics) using the
|
||||
// same renderer history does. No history refetch, no end-of-stream flicker.
|
||||
@@ -4726,6 +5117,9 @@ import {
|
||||
// reload from the DB for the full canonical render.
|
||||
if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove();
|
||||
if (holder.parentNode) holder.remove();
|
||||
if (metricsData) {
|
||||
chatRenderer.recordSessionMetricsCost(metricsData, sessionId);
|
||||
}
|
||||
if (onThisSession) sessionModule.selectSession(sessionId);
|
||||
else sessionModule.loadSessions();
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Select and update the response holder for a route-provenance event. */
|
||||
export function applyModelRouteEventState(event, holder, roundHolder, defaultModel = '') {
|
||||
const target = event && event.round && roundHolder ? roundHolder : holder;
|
||||
if (!target) return null;
|
||||
|
||||
target._requestedModel = (
|
||||
event.requested_model
|
||||
|| event.selected_model
|
||||
|| target._requestedModel
|
||||
|| defaultModel
|
||||
);
|
||||
target._actualModel = (
|
||||
event.model
|
||||
|| event.answered_by
|
||||
|| target._actualModel
|
||||
|| target._requestedModel
|
||||
);
|
||||
const hasEndpointRoute = Boolean(
|
||||
event.requested_endpoint_id
|
||||
|| event.selected_endpoint_id
|
||||
|| event.endpoint_id
|
||||
|| event.answered_by_endpoint_id
|
||||
|| event.requested_endpoint_label
|
||||
|| event.selected_endpoint_label
|
||||
|| event.endpoint_label
|
||||
|| event.answered_by_endpoint_label
|
||||
|| target._requestedEndpointLabel
|
||||
);
|
||||
if (hasEndpointRoute) {
|
||||
target._requestedEndpointId = (
|
||||
event.requested_endpoint_id
|
||||
|| event.selected_endpoint_id
|
||||
|| target._requestedEndpointId
|
||||
|| null
|
||||
);
|
||||
target._requestedEndpointLabel = (
|
||||
event.requested_endpoint_label
|
||||
|| event.selected_endpoint_label
|
||||
|| target._requestedEndpointLabel
|
||||
|| 'Selected route'
|
||||
);
|
||||
target._actualEndpointId = (
|
||||
event.endpoint_id
|
||||
|| event.answered_by_endpoint_id
|
||||
|| target._actualEndpointId
|
||||
|| target._requestedEndpointId
|
||||
|| null
|
||||
);
|
||||
target._actualEndpointLabel = (
|
||||
event.endpoint_label
|
||||
|| event.answered_by_endpoint_label
|
||||
|| target._actualEndpointLabel
|
||||
|| target._requestedEndpointLabel
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Copy the active route into the bubble created for the next Agent round. */
|
||||
export function inheritModelRouteState(holder, roundHolder, target, defaultModel = '') {
|
||||
if (!target) return null;
|
||||
const source = roundHolder || holder;
|
||||
target._requestedModel = source?._requestedModel || defaultModel;
|
||||
target._actualModel = source?._actualModel || target._requestedModel;
|
||||
if (source?._requestedEndpointLabel || source?._actualEndpointLabel) {
|
||||
target._requestedEndpointId = source?._requestedEndpointId || null;
|
||||
target._requestedEndpointLabel = source?._requestedEndpointLabel || 'Selected route';
|
||||
target._actualEndpointId = source?._actualEndpointId || target._requestedEndpointId;
|
||||
target._actualEndpointLabel = source?._actualEndpointLabel || target._requestedEndpointLabel;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Apply final/metrics provenance to the active round, not the first bubble. */
|
||||
export function applyModelMetricsState(metrics, holder, roundHolder, defaultModel = '') {
|
||||
const target = roundHolder || holder;
|
||||
if (!target || !metrics) return target || null;
|
||||
const roundModels = Array.isArray(metrics.round_models) ? metrics.round_models : [];
|
||||
const roundModel = roundHolder && roundModels.length
|
||||
? roundModels[roundModels.length - 1]
|
||||
: null;
|
||||
target._requestedModel = metrics.requested_model || target._requestedModel || defaultModel;
|
||||
target._actualModel = roundModel || metrics.model || target._actualModel || target._requestedModel;
|
||||
const roundEndpointIds = Array.isArray(metrics.round_endpoint_ids) ? metrics.round_endpoint_ids : [];
|
||||
const roundEndpointLabels = Array.isArray(metrics.round_endpoint_labels) ? metrics.round_endpoint_labels : [];
|
||||
if (
|
||||
metrics.requested_endpoint_label
|
||||
|| metrics.endpoint_label
|
||||
|| roundEndpointLabels.length
|
||||
|| target._requestedEndpointLabel
|
||||
) {
|
||||
target._requestedEndpointId = metrics.requested_endpoint_id || target._requestedEndpointId || null;
|
||||
target._requestedEndpointLabel = metrics.requested_endpoint_label || target._requestedEndpointLabel || 'Selected route';
|
||||
const hasRoundEndpointId = Boolean(roundHolder && roundEndpointIds.length);
|
||||
const hasRoundEndpointLabel = Boolean(roundHolder && roundEndpointLabels.length);
|
||||
target._actualEndpointId = hasRoundEndpointId
|
||||
? roundEndpointIds[roundEndpointIds.length - 1]
|
||||
: (metrics.endpoint_id || target._actualEndpointId || target._requestedEndpointId);
|
||||
target._actualEndpointLabel = hasRoundEndpointLabel
|
||||
? roundEndpointLabels[roundEndpointLabels.length - 1]
|
||||
: (metrics.endpoint_label || target._actualEndpointLabel || target._requestedEndpointLabel);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
+255
-47
@@ -481,7 +481,7 @@ const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<
|
||||
// Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one
|
||||
// pipe is required around `end`: with both optional (`\|?end\|?`) this also ate
|
||||
// a bare `end` on its own line, breaking Ruby/Lua/shell snippets (#5547).
|
||||
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
|
||||
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)/gi;
|
||||
// Self-narration about tool results (model echoing stdout/exit_code)
|
||||
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
|
||||
|
||||
@@ -615,10 +615,36 @@ export function sameModelName(left, right) {
|
||||
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
|
||||
}
|
||||
|
||||
export function modelRouteLabel(requestedModel, actualModel) {
|
||||
function shortEndpointLabel(label) {
|
||||
const value = modelValue(label);
|
||||
if (!value) return '';
|
||||
return value.length > 18 ? value.slice(0, 17) + '…' : value;
|
||||
}
|
||||
|
||||
export function modelRouteLabel(
|
||||
requestedModel,
|
||||
actualModel,
|
||||
requestedEndpointLabel = '',
|
||||
actualEndpointLabel = '',
|
||||
requestedEndpointId = '',
|
||||
actualEndpointId = '',
|
||||
) {
|
||||
const requested = modelValue(requestedModel);
|
||||
const actual = modelValue(actualModel) || requested;
|
||||
if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested);
|
||||
const requestedRoute = modelValue(requestedEndpointId || requestedEndpointLabel);
|
||||
const actualRoute = modelValue(actualEndpointId || actualEndpointLabel);
|
||||
const routeChanged = Boolean(
|
||||
actualRoute
|
||||
&& requestedRoute
|
||||
&& actualRoute !== requestedRoute
|
||||
);
|
||||
if (!requested || sameModelName(requested, actual)) {
|
||||
const model = shortModel(actual || requested);
|
||||
if (!routeChanged) return model;
|
||||
const from = shortEndpointLabel(requestedEndpointLabel || 'Selected route');
|
||||
const to = shortEndpointLabel(actualEndpointLabel || actualEndpointId);
|
||||
return model + ' (' + from + ' -> ' + to + ')';
|
||||
}
|
||||
return shortModel(requested) + ' -> ' + shortModel(actual);
|
||||
}
|
||||
|
||||
@@ -629,10 +655,24 @@ export function replyModelPair(modelName, metadata) {
|
||||
if (actualFromMeta || requestedFromMeta) {
|
||||
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
|
||||
const requested = requestedFromMeta || actual;
|
||||
return { requestedModel: requested, actualModel: actual };
|
||||
return {
|
||||
requestedModel: requested,
|
||||
actualModel: actual,
|
||||
requestedEndpointId: meta.requested_endpoint_id || null,
|
||||
requestedEndpointLabel: meta.requested_endpoint_label || 'Selected route',
|
||||
actualEndpointId: meta.endpoint_id || null,
|
||||
actualEndpointLabel: meta.endpoint_label || meta.requested_endpoint_label || 'Selected route',
|
||||
};
|
||||
}
|
||||
const fallback = modelValue(modelName);
|
||||
return { requestedModel: fallback, actualModel: fallback };
|
||||
return {
|
||||
requestedModel: fallback,
|
||||
actualModel: fallback,
|
||||
requestedEndpointId: null,
|
||||
requestedEndpointLabel: 'Selected route',
|
||||
actualEndpointId: null,
|
||||
actualEndpointLabel: 'Selected route',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -824,12 +864,50 @@ export function isCostTrackedEndpoint(url) {
|
||||
}
|
||||
|
||||
/** Cost for the current turn, returning null for non-billable endpoints. */
|
||||
function _billableCost(model, inputTokens, outputTokens) {
|
||||
const url = _currentEndpointUrl();
|
||||
if (!isCostTrackedEndpoint(url)) return null;
|
||||
function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) {
|
||||
// Foreground fallback can answer on a different endpoint than the session's
|
||||
// selected route. Prefer the backend's non-secret actual-route
|
||||
// classification; retain the selected-endpoint check for older history.
|
||||
if (endpointCostTracked === false) return null;
|
||||
const selectedUrl = selectedEndpointUrl === undefined
|
||||
? _currentEndpointUrl()
|
||||
: selectedEndpointUrl;
|
||||
if (endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)) {
|
||||
return null;
|
||||
}
|
||||
return getModelCost(model, inputTokens, outputTokens);
|
||||
}
|
||||
|
||||
/** Sum cost using the route/model that produced each Agent round. */
|
||||
function _metricsBillableCost(metrics, model, inputTokens, outputTokens, selectedEndpointUrl) {
|
||||
const buckets = Array.isArray(metrics.usage_buckets) ? metrics.usage_buckets : [];
|
||||
if (!buckets.length) {
|
||||
return _billableCost(
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
metrics.endpoint_cost_tracked,
|
||||
selectedEndpointUrl,
|
||||
);
|
||||
}
|
||||
let total = 0;
|
||||
let hasPricedUsage = false;
|
||||
for (const bucket of buckets) {
|
||||
if (!bucket || typeof bucket !== 'object') continue;
|
||||
const bucketCost = _billableCost(
|
||||
bucket.model || model,
|
||||
Number(bucket.input_tokens) || 0,
|
||||
Number(bucket.output_tokens) || 0,
|
||||
bucket.endpoint_cost_tracked,
|
||||
selectedEndpointUrl,
|
||||
);
|
||||
if (bucketCost === null) continue;
|
||||
total += bucketCost;
|
||||
hasPricedUsage = true;
|
||||
}
|
||||
return hasPricedUsage ? total : null;
|
||||
}
|
||||
|
||||
export function getImageCost(model, quality, size) {
|
||||
if (!model) return null;
|
||||
const m = model.toLowerCase();
|
||||
@@ -844,6 +922,9 @@ export function getImageCost(model, quality, size) {
|
||||
|
||||
/* ── Session cost helpers ─────────────────────────────────────────── */
|
||||
const _COST_KEY = 'ody-session-cost';
|
||||
const _COST_RUNS_KEY = 'ody-session-cost-runs';
|
||||
const _MAX_COST_RUNS_PER_SESSION = 256;
|
||||
const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger';
|
||||
|
||||
/** Return the accumulated cost for the current (or given) session. */
|
||||
export function getSessionCost(sessionId) {
|
||||
@@ -851,7 +932,14 @@ export function getSessionCost(sessionId) {
|
||||
if (!sid) return 0;
|
||||
try {
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
return costs[sid] || 0;
|
||||
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
|
||||
const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
|
||||
? Object.values(runCosts[sid])
|
||||
: [];
|
||||
return (costs[sid] || 0) + recordedRuns.reduce(
|
||||
(total, value) => total + (Number(value) || 0),
|
||||
0,
|
||||
);
|
||||
} catch (_e) { return 0; }
|
||||
}
|
||||
|
||||
@@ -863,6 +951,9 @@ export function resetSessionCost(sessionId) {
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
delete costs[sid];
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
|
||||
delete runCosts[sid];
|
||||
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
|
||||
} catch (_e) { /* ignore */ }
|
||||
updateSessionCostUI();
|
||||
}
|
||||
@@ -871,21 +962,8 @@ export function resetSessionCost(sessionId) {
|
||||
export function updateSessionCostUI() {
|
||||
const el = document.getElementById('session-cost-display');
|
||||
if (!el) return;
|
||||
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
|
||||
// cloud-rate calculation may have left in localStorage for this session.
|
||||
const _url = _currentEndpointUrl();
|
||||
if (!isCostTrackedEndpoint(_url)) {
|
||||
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||
if (sid && getSessionCost(sid) > 0) {
|
||||
try {
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
delete costs[sid];
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
// The ledger records billable work already performed in this session. A
|
||||
// selected local endpoint does not erase cost from a paid fallback route.
|
||||
const cost = getSessionCost();
|
||||
if (cost > 0) {
|
||||
el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
|
||||
@@ -895,6 +973,94 @@ export function updateSessionCostUI() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Record one metrics payload in a session ledger at most once. */
|
||||
export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl) {
|
||||
if (!metrics || typeof metrics !== 'object') return null;
|
||||
const cost = _metricsBillableCost(
|
||||
metrics,
|
||||
metrics.model || 'Unknown',
|
||||
metrics.input_tokens || 0,
|
||||
metrics.output_tokens || 0,
|
||||
selectedEndpointUrl,
|
||||
);
|
||||
if (metrics._fromHistory) return cost;
|
||||
const sid = sessionId || (
|
||||
window.sessionModule && window.sessionModule.getCurrentSessionId()
|
||||
);
|
||||
if (!sid || cost === null) return cost;
|
||||
const runId = typeof metrics._costRecordId === 'string'
|
||||
? metrics._costRecordId.trim()
|
||||
: '';
|
||||
if ((metrics._costRecorded || metrics._costRecordPending) && !runId) return cost;
|
||||
// Recorded is only set once the write actually runs; pending covers the
|
||||
// window while the write waits on the cross-tab lock, so a replay in that
|
||||
// window cannot double-add and a tab closed mid-queue never claims recorded.
|
||||
metrics._costRecordPending = true;
|
||||
const writeCost = () => {
|
||||
if (runId) {
|
||||
try {
|
||||
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
|
||||
const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
|
||||
? runCosts[sid]
|
||||
: {};
|
||||
// Assigning by detached-run identity is replay-idempotent even when a
|
||||
// refresh produces a fresh metrics object. The Web Lock around this
|
||||
// read/modify/write also keeps distinct runs from two tabs from
|
||||
// overwriting one another's stale snapshot.
|
||||
sessionRuns[runId] = cost;
|
||||
const entries = Object.entries(sessionRuns);
|
||||
if (entries.length > _MAX_COST_RUNS_PER_SESSION) {
|
||||
const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION);
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
costs[sid] = (costs[sid] || 0) + overflow.reduce(
|
||||
(total, entry) => total + (Number(entry[1]) || 0),
|
||||
0,
|
||||
);
|
||||
overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]);
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||
}
|
||||
runCosts[sid] = sessionRuns;
|
||||
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
|
||||
} catch (_e) { /* ignore */ }
|
||||
} else {
|
||||
try {
|
||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
costs[sid] = (costs[sid] || 0) + cost;
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
||||
} catch (_e) { /* ignore */ }
|
||||
}
|
||||
metrics._costRecorded = true;
|
||||
metrics._costRecordPending = false;
|
||||
const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||
if (currentSid === sid) updateSessionCostUI();
|
||||
};
|
||||
|
||||
let writeStarted = false;
|
||||
const guardedWrite = () => {
|
||||
writeStarted = true;
|
||||
writeCost();
|
||||
};
|
||||
try {
|
||||
if (
|
||||
typeof navigator !== 'undefined'
|
||||
&& navigator.locks
|
||||
&& typeof navigator.locks.request === 'function'
|
||||
) {
|
||||
const pendingWrite = navigator.locks.request(_COST_LEDGER_LOCK, guardedWrite);
|
||||
if (pendingWrite && typeof pendingWrite.catch === 'function') {
|
||||
pendingWrite.catch(() => {
|
||||
if (!writeStarted) guardedWrite();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
guardedWrite();
|
||||
}
|
||||
} catch (_e) {
|
||||
if (!writeStarted) guardedWrite();
|
||||
}
|
||||
return cost;
|
||||
}
|
||||
|
||||
/** Create a timestamp span for role labels.
|
||||
* Pass an ISO string / Date / epoch-ms to render the message's own time
|
||||
* (used when replaying history). Falls back to "now" when no value is given. */
|
||||
@@ -1874,23 +2040,19 @@ export function displayMetrics(messageElement, metrics) {
|
||||
const isReal = metrics.usage_source === 'real';
|
||||
const ctxPct = metrics.context_percent;
|
||||
const model = metrics.model || 'Unknown';
|
||||
const cost = _billableCost(model, inputTokens, outputTokens);
|
||||
const cost = _metricsBillableCost(
|
||||
metrics,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
);
|
||||
|
||||
// Nothing useful to show — bail out (only if ALL metrics are missing)
|
||||
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
|
||||
|
||||
// Accumulate session cost (only on fresh metrics, not history reload)
|
||||
if (!metrics._fromHistory) {
|
||||
const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
||||
if (_sid && cost !== null) {
|
||||
try {
|
||||
const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||
_costs[_sid] = (_costs[_sid] || 0) + cost;
|
||||
localStorage.setItem(_COST_KEY, JSON.stringify(_costs));
|
||||
} catch (_e) { /* ignore */ }
|
||||
updateSessionCostUI();
|
||||
}
|
||||
}
|
||||
// Rendering can occur when metrics arrive and again after [DONE]. The
|
||||
// ledger mutation is idempotent for that shared payload.
|
||||
recordSessionMetricsCost(metrics);
|
||||
|
||||
// Keep token counts in the Message Stats popup; the footer should stay slim.
|
||||
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
|
||||
@@ -2307,9 +2469,19 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
||||
|
||||
// --- Agent multi-bubble reconstruction from saved metadata ---
|
||||
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
||||
if (
|
||||
role === 'assistant'
|
||||
&& metadata
|
||||
&& (
|
||||
(Array.isArray(metadata.tool_events) && metadata.tool_events.length > 0)
|
||||
|| (Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1)
|
||||
)
|
||||
) {
|
||||
const roundTexts = metadata.round_texts || [];
|
||||
const toolEvents = metadata.tool_events;
|
||||
const roundModels = metadata.round_models || [];
|
||||
const roundEndpointIds = metadata.round_endpoint_ids || [];
|
||||
const roundEndpointLabels = metadata.round_endpoint_labels || [];
|
||||
const toolEvents = metadata.tool_events || [];
|
||||
let pendingAskUser = null;
|
||||
let lastWrap = null;
|
||||
let firstMsgAi = null;
|
||||
@@ -2322,7 +2494,8 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
toolsByRound[r].push(ev);
|
||||
}
|
||||
|
||||
const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length);
|
||||
const toolRounds = Object.keys(toolsByRound).map(Number);
|
||||
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length);
|
||||
|
||||
for (let r = 0; r < maxRound; r++) {
|
||||
const roundNum = r + 1;
|
||||
@@ -2334,10 +2507,31 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const roleEl = document.createElement('div');
|
||||
roleEl.className = 'role';
|
||||
const pair = replyModelPair(modelName, metadata);
|
||||
const contModel = pair.actualModel || pair.requestedModel;
|
||||
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
|
||||
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
|
||||
roleEl.title = pair.requestedModel + ' -> ' + contModel;
|
||||
const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;
|
||||
const contEndpointId = r < roundEndpointIds.length
|
||||
? roundEndpointIds[r]
|
||||
: pair.actualEndpointId;
|
||||
const contEndpointLabel = r < roundEndpointLabels.length
|
||||
? roundEndpointLabels[r]
|
||||
: pair.actualEndpointLabel;
|
||||
roleEl.textContent = modelRouteLabel(
|
||||
pair.requestedModel,
|
||||
contModel,
|
||||
pair.requestedEndpointLabel,
|
||||
contEndpointLabel,
|
||||
pair.requestedEndpointId,
|
||||
contEndpointId,
|
||||
);
|
||||
if (
|
||||
pair.requestedModel
|
||||
&& contModel
|
||||
&& (
|
||||
!sameModelName(pair.requestedModel, contModel)
|
||||
|| (pair.requestedEndpointId && contEndpointId && pair.requestedEndpointId !== contEndpointId)
|
||||
)
|
||||
) {
|
||||
roleEl.title = pair.requestedModel + ' -> ' + contModel
|
||||
+ ' (' + pair.requestedEndpointLabel + ' -> ' + contEndpointLabel + ')';
|
||||
}
|
||||
applyModelColor(roleEl, contModel);
|
||||
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
|
||||
@@ -2492,7 +2686,14 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const isCompacted = metadata?.compacted;
|
||||
const replyModels = replyModelPair(modelName, metadata);
|
||||
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
|
||||
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(replyModels.requestedModel, resolvedModel);
|
||||
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(
|
||||
replyModels.requestedModel,
|
||||
resolvedModel,
|
||||
replyModels.requestedEndpointLabel,
|
||||
replyModels.actualEndpointLabel,
|
||||
replyModels.requestedEndpointId,
|
||||
replyModels.actualEndpointId,
|
||||
);
|
||||
if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
|
||||
_roleText += ' (Research)';
|
||||
}
|
||||
@@ -2503,8 +2704,14 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
}
|
||||
r.textContent = _roleText;
|
||||
if (role !== 'user') {
|
||||
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
|
||||
r.title = replyModels.requestedModel + ' -> ' + resolvedModel;
|
||||
const endpointChanged = Boolean(
|
||||
replyModels.requestedEndpointId
|
||||
&& replyModels.actualEndpointId
|
||||
&& replyModels.requestedEndpointId !== replyModels.actualEndpointId
|
||||
);
|
||||
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && (!sameModelName(replyModels.requestedModel, resolvedModel) || endpointChanged)) {
|
||||
r.title = replyModels.requestedModel + ' -> ' + resolvedModel
|
||||
+ ' (' + replyModels.requestedEndpointLabel + ' -> ' + replyModels.actualEndpointLabel + ')';
|
||||
}
|
||||
if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
|
||||
r.appendChild(roleTimestamp(metadata?.timestamp));
|
||||
@@ -2788,6 +2995,7 @@ const chatRenderer = {
|
||||
getSessionCost,
|
||||
resetSessionCost,
|
||||
updateSessionCostUI,
|
||||
recordSessionMetricsCost,
|
||||
roleTimestamp,
|
||||
stripToolBlocks,
|
||||
copyMessageText,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Build a terminal stream error while preserving provider-supplied text. */
|
||||
export function createTerminalStreamError(payload = {}) {
|
||||
const rawError = payload.error;
|
||||
const message = (
|
||||
payload.text
|
||||
|| (typeof rawError === 'string' ? rawError : rawError?.message)
|
||||
|| `Error ${payload.status || 'unknown'}`
|
||||
);
|
||||
const error = new Error(message);
|
||||
error.name = 'TerminalStreamError';
|
||||
error.terminalStreamError = true;
|
||||
error.status = payload.status;
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Only connection-class stream failures are safe to resubmit automatically. */
|
||||
export function isRecoverableStreamError(error) {
|
||||
if (!error || error.terminalStreamError || error.name === 'TerminalStreamError') return false;
|
||||
if (error.name === 'TypeError') return true;
|
||||
const message = (error.message || '').toLowerCase();
|
||||
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(message)) return false;
|
||||
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(message);
|
||||
}
|
||||
+1
-81
@@ -445,14 +445,7 @@ async function initDefaultChat() {
|
||||
var epSel = el('set-defaultEpSelect');
|
||||
var modelSel = el('set-defaultModelSelect');
|
||||
var msg = el('set-defaultChatMsg');
|
||||
var fbContainer = el('set-defaultFallbacks');
|
||||
var addFbBtn = el('set-defaultAddFallback');
|
||||
var _endpoints = [];
|
||||
var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails
|
||||
|
||||
function enabledEndpoints() {
|
||||
return _endpoints.filter(function(e) { return e.is_enabled; });
|
||||
}
|
||||
|
||||
// Fill any <select> with the models for a given endpoint id.
|
||||
function fillModels(selectEl, epId, selected) {
|
||||
@@ -469,64 +462,6 @@ async function initDefaultChat() {
|
||||
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
|
||||
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
|
||||
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
|
||||
renderFallbacks();
|
||||
}
|
||||
|
||||
// Render the fallback chain. Each row is endpoint + model + remove.
|
||||
function renderFallbacks() {
|
||||
fbContainer.innerHTML = '';
|
||||
_fallbacks.forEach(function(fb, idx) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'settings-fallback-row';
|
||||
|
||||
var num = document.createElement('span');
|
||||
num.className = 'settings-fallback-num';
|
||||
num.textContent = (idx + 1) + '.';
|
||||
|
||||
var epS = document.createElement('select');
|
||||
epS.className = 'settings-select';
|
||||
enabledEndpoints().forEach(function(ep) {
|
||||
var o = document.createElement('option');
|
||||
o.value = ep.id;
|
||||
o.textContent = ep.name + (ep.online ? '' : ' (offline)');
|
||||
epS.appendChild(o);
|
||||
});
|
||||
var first = enabledEndpoints()[0];
|
||||
epS.value = fb.endpoint_id || (first ? first.id : '');
|
||||
|
||||
var mS = document.createElement('select');
|
||||
mS.className = 'settings-select';
|
||||
fillModels(mS, epS.value, fb.model);
|
||||
|
||||
// Keep the model in sync with the values actually shown.
|
||||
fb.endpoint_id = epS.value;
|
||||
fb.model = mS.value;
|
||||
|
||||
epS.addEventListener('change', function() {
|
||||
fb.endpoint_id = epS.value;
|
||||
fillModels(mS, epS.value, '');
|
||||
fb.model = mS.value;
|
||||
saveDefault();
|
||||
});
|
||||
mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); });
|
||||
|
||||
var rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
rm.className = 'settings-fallback-remove';
|
||||
rm.title = 'Remove fallback';
|
||||
rm.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
|
||||
rm.addEventListener('click', function() {
|
||||
_fallbacks.splice(idx, 1);
|
||||
renderFallbacks();
|
||||
saveDefault();
|
||||
});
|
||||
|
||||
row.appendChild(num);
|
||||
row.appendChild(epS);
|
||||
row.appendChild(mS);
|
||||
row.appendChild(rm);
|
||||
fbContainer.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -534,12 +469,6 @@ async function initDefaultChat() {
|
||||
var settings = await res.json();
|
||||
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
|
||||
refreshModels(settings.default_model || '');
|
||||
_fallbacks = Array.isArray(settings.default_model_fallbacks)
|
||||
? settings.default_model_fallbacks.map(function(f) {
|
||||
return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' };
|
||||
})
|
||||
: [];
|
||||
renderFallbacks();
|
||||
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
||||
|
||||
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
|
||||
@@ -547,13 +476,11 @@ async function initDefaultChat() {
|
||||
|
||||
async function saveDefault() {
|
||||
try {
|
||||
var clean = _fallbacks.filter(function(f) { return f.endpoint_id && f.model; });
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
default_endpoint_id: epSel.value,
|
||||
default_model: modelSel.value,
|
||||
default_model_fallbacks: clean
|
||||
default_model: modelSel.value
|
||||
})
|
||||
});
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
@@ -561,13 +488,6 @@ async function initDefaultChat() {
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
if (addFbBtn) addFbBtn.addEventListener('click', function() {
|
||||
var first = enabledEndpoints()[0];
|
||||
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
|
||||
renderFallbacks();
|
||||
saveDefault();
|
||||
});
|
||||
|
||||
_registerAiEndpointRefresh(function(endpoints) {
|
||||
_endpoints = endpoints;
|
||||
refreshEndpointOptions(epSel.value, modelSel.value);
|
||||
|
||||
@@ -2027,12 +2027,12 @@ async function _cmdUsage(args, ctx) {
|
||||
const messageCount = Number(session?.message_count || 0);
|
||||
const totalTokens = Number(session?.total_tokens || 0);
|
||||
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
|
||||
const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
||||
const costLine = costTracked
|
||||
? (cost > 0
|
||||
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
||||
: 'Estimated local cost: unavailable or zero')
|
||||
: 'Estimated local cost: not tracked for this endpoint';
|
||||
const cost = chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
||||
const costLine = cost > 0
|
||||
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
||||
: costTracked
|
||||
? 'Estimated local cost: unavailable or zero'
|
||||
: 'Estimated local cost: no billable usage recorded';
|
||||
|
||||
slashReply(`<pre>${[
|
||||
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
|
||||
|
||||
+86
-13
@@ -4,6 +4,13 @@
|
||||
* ASCII Spinner Module for AI thinking/processing status
|
||||
*/
|
||||
|
||||
// How long a canvas spinner may keep animating before its element has ever
|
||||
// been inserted into the document. start() runs synchronously, before the
|
||||
// caller appends the element, so frame 1 is always disconnected. Callers do
|
||||
// append in the same task, so anything past this window means the element is
|
||||
// never coming and the frames are drawing for nobody.
|
||||
const UNATTACHED_GRACE_MS = 2000;
|
||||
|
||||
class Spinner {
|
||||
constructor(message = "AI is processing", style = "right", animation = "spinner") {
|
||||
// Different animation frames
|
||||
@@ -21,6 +28,9 @@ class Spinner {
|
||||
this.intervalId = null;
|
||||
this.rafId = null;
|
||||
this.element = null;
|
||||
this._wpWasConnected = false;
|
||||
this._wpUnattachedSince = null;
|
||||
this._visHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +84,7 @@ class Spinner {
|
||||
}
|
||||
|
||||
_drawSineWave() {
|
||||
if (!this.isRunning) return;
|
||||
const ctx = this._ctx;
|
||||
const W = this._canvas.width;
|
||||
const H = this._canvas.height;
|
||||
@@ -120,9 +131,7 @@ class Spinner {
|
||||
ctx.fillStyle = 'rgba(156, 222, 242, 0.9)';
|
||||
ctx.fill();
|
||||
|
||||
if (this.isRunning) {
|
||||
this.rafId = requestAnimationFrame(() => this._drawSineWave());
|
||||
}
|
||||
if (this.isRunning) this._requestFrame();
|
||||
}
|
||||
|
||||
_createWhirlpoolElement() {
|
||||
@@ -158,6 +167,7 @@ class Spinner {
|
||||
}
|
||||
|
||||
_drawWhirlpool() {
|
||||
if (!this.isRunning) return;
|
||||
const ctx = this._wpCtx;
|
||||
const W = this._wpCanvas.width;
|
||||
const H = this._wpCanvas.height;
|
||||
@@ -229,18 +239,77 @@ class Spinner {
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (!this.isRunning) return;
|
||||
// Leak-safe self-terminate: stop once our element WAS in the DOM and then
|
||||
// got removed (e.g. a loading row replaced by results). But keep spinning
|
||||
// before it's first appended — start() runs synchronously, before the
|
||||
// caller inserts the element, so it isn't connected on frame 1.
|
||||
// Leak-safe self-terminate. "Nobody can see this spinner" has two shapes
|
||||
// and we have to catch both:
|
||||
// 1. the element WAS in the DOM and then got removed (a loading row
|
||||
// replaced by results);
|
||||
// 2. the element was NEVER inserted, and the grace window for inserting
|
||||
// it has expired. The caller started a spinner and then took an early
|
||||
// return (aborted request, panel that resolved from cache), so no
|
||||
// frame we draw will ever be observed.
|
||||
// Case 2 is why this needs a deadline at all: while the element has never
|
||||
// been connected, `!this._wpWasConnected` stays true forever, so without
|
||||
// the grace check the loop re-arms until the tab closes.
|
||||
const connected = !!(this.element && this.element.isConnected);
|
||||
if (connected) this._wpWasConnected = true;
|
||||
if (connected || !this._wpWasConnected) {
|
||||
this.rafId = requestAnimationFrame(() => this._drawWhirlpool());
|
||||
} else {
|
||||
this.isRunning = false;
|
||||
if (connected) {
|
||||
this._wpWasConnected = true;
|
||||
this._wpUnattachedSince = null;
|
||||
} else if (!this._wpWasConnected) {
|
||||
if (this._wpUnattachedSince === null) this._wpUnattachedSince = performance.now();
|
||||
if (performance.now() - this._wpUnattachedSince > UNATTACHED_GRACE_MS) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (connected || !this._wpWasConnected) {
|
||||
this._requestFrame();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the next animation frame. Clearing rafId as the callback enters keeps
|
||||
* it a truthful "a frame is pending" flag, which is what stop() and the
|
||||
* visibility handler cancel against.
|
||||
*/
|
||||
_requestFrame() {
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.rafId = null;
|
||||
if (this.animation === 'sinewave') this._drawSineWave();
|
||||
else this._drawWhirlpool();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop drawing while the tab is hidden. Browsers throttle background rAF but
|
||||
* do not reliably stop the canvas work, and a spinner nobody is looking at
|
||||
* should cost nothing. The listener is owned by start()/stop() so it is never
|
||||
* left behind on a dead spinner.
|
||||
*/
|
||||
_armVisibilityPause() {
|
||||
if (this._visHandler) return;
|
||||
this._visHandler = () => {
|
||||
if (document.hidden) {
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
} else if (this.isRunning && !this.rafId) {
|
||||
// Reset the wave clock so the hidden interval doesn't arrive as one
|
||||
// huge dt and skip the animation forward.
|
||||
this._wavePrev = performance.now();
|
||||
this._requestFrame();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', this._visHandler);
|
||||
}
|
||||
|
||||
_disarmVisibilityPause() {
|
||||
if (!this._visHandler) return;
|
||||
document.removeEventListener('visibilitychange', this._visHandler);
|
||||
this._visHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,12 +341,15 @@ class Spinner {
|
||||
|
||||
if (this.animation === 'sinewave') {
|
||||
this._wavePrev = performance.now();
|
||||
this._armVisibilityPause();
|
||||
this._drawSineWave();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.animation === 'whirlpool') {
|
||||
this._wpStartedAt = performance.now();
|
||||
this._wpUnattachedSince = null;
|
||||
this._armVisibilityPause();
|
||||
this._drawWhirlpool();
|
||||
return;
|
||||
}
|
||||
@@ -302,6 +374,7 @@ class Spinner {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
this._disarmVisibilityPause();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// static/js/ui_visibility.js
|
||||
//
|
||||
// Per-item visibility for the sidebar and collapsed icon rail. Drives the
|
||||
// Settings → Appearance ("Customize UI") checkboxes, persisted in localStorage
|
||||
// under `odysseus-ui-visibility` (loaded/saved by app.js).
|
||||
//
|
||||
// Each key maps to the CSS selector(s) it controls. Tool/section selectors pair
|
||||
// the full-sidebar element with its #rail-* launcher so a tab hidden in the
|
||||
// full view also hides when the sidebar is minimized to the icon rail (id
|
||||
// mapping mirrors _railToolMap in app.js; #tool-library-btn ↔ #rail-archive).
|
||||
|
||||
// Selector map: UI customization key → CSS selector(s) for its target(s).
|
||||
export const UI_VIS_MAP = {
|
||||
'sidebar-brand': '.sidebar-brand-title',
|
||||
'sidebar-new-chat': '#sidebar-new-chat-btn',
|
||||
'sidebar-search': '#sidebar-search-btn',
|
||||
'sessions-section': '#sessions-section',
|
||||
'email-section': '#email-section, #rail-email',
|
||||
'tools-section': '#tools-section',
|
||||
// Per-tool entries pair the sidebar button with its rail launcher.
|
||||
'tool-calendar': '#tool-calendar-btn, #rail-calendar',
|
||||
'tool-compare': '#tool-compare-btn, #rail-compare',
|
||||
'tool-cookbook': '#tool-cookbook-btn, #rail-cookbook',
|
||||
'tool-research': '#tool-research-btn, #rail-research',
|
||||
'tool-gallery': '#tool-gallery-btn, #rail-gallery',
|
||||
'tool-library': '#tool-library-btn, #rail-archive',
|
||||
'tool-memory': '#tool-memory-btn, #rail-memory',
|
||||
'tool-notes': '#tool-notes-btn, #rail-notes',
|
||||
'tool-tasks': '#tool-tasks-btn, #rail-tasks',
|
||||
'tool-theme': '#tool-theme-btn, #rail-theme',
|
||||
'user-bar': '#user-bar-profile',
|
||||
'sidebar-settings-btn':'#user-bar-settings',
|
||||
'chat-meta': '.chat-meta-overlay',
|
||||
'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
|
||||
'incognito-btn': '.incognito-btn',
|
||||
'web-toggle-btn': '#web-toggle-btn',
|
||||
'doc-toggle-btn': '#overflow-doc-btn',
|
||||
'rag-toggle-btn': '#overflow-rag-btn',
|
||||
'bash-toggle-btn': '#bash-toggle-btn',
|
||||
'overflow-plus-btn': '.overflow-wrapper',
|
||||
'mode-toggle': '.mode-toggle',
|
||||
'preset-mini-btn': '#overflow-preset-btn',
|
||||
'attach-btn': '#overflow-attach-btn',
|
||||
'research-btn': '#overflow-research-btn',
|
||||
'rail-new-chat': '#rail-new-session',
|
||||
};
|
||||
|
||||
// Keys hidden by default on first run (no localStorage yet).
|
||||
export const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||
|
||||
/**
|
||||
* Resolve every UI_VIS_MAP selector to visible (true) or hidden (false) for the
|
||||
* given saved state. Pure: no DOM, no localStorage — app.js applies the result.
|
||||
*
|
||||
* A key is visible when its stored value is not `false`, defaulting to on
|
||||
* unless it is in UI_VIS_DEFAULT_OFF. Per-tool entries also require the Tools
|
||||
* section to be on: hiding Tools hides every tool, mirroring the full sidebar
|
||||
* where the #tools-section container already hides them (the rail has no
|
||||
* container, so this rule keeps it in sync).
|
||||
*
|
||||
* @param {Record<string, boolean>} state
|
||||
* @returns {Record<string, boolean>} selector → visible
|
||||
*/
|
||||
export const resolveVisibility = (state = {}) => {
|
||||
const toolsOn = state['tools-section'] !== false;
|
||||
const out = {};
|
||||
for (const [key, selector] of Object.entries(UI_VIS_MAP)) {
|
||||
let visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
|
||||
if (!toolsOn && key.startsWith('tool-')) visible = false;
|
||||
out[selector] = visible;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Regression guard for #5558 — POST /api/personal/add_directory must not run
|
||||
the indexing job on the event loop.
|
||||
|
||||
The handler is ``async def`` but called ``rag.index_personal_documents``
|
||||
(os.walk + file reads + per-chunk embedding + Chroma inserts) inline, so
|
||||
FastAPI ran the whole job on the event loop and every other request queued
|
||||
behind it: indexing a real directory froze the UI and API for 25+ minutes.
|
||||
``personal_docs_manager.add_directory`` sits in the same blocking section — it
|
||||
triggers ``refresh_index()``, which re-extracts text across tracked dirs.
|
||||
|
||||
These tests build the real router with fake managers and compare the thread
|
||||
the indexing work runs on against the event loop's thread.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _serialization_probe():
|
||||
"""Shared counter proving two critical sections never overlap."""
|
||||
state = {"active": 0, "max_active": 0}
|
||||
lock = threading.Lock()
|
||||
|
||||
def enter():
|
||||
with lock:
|
||||
state["active"] += 1
|
||||
state["max_active"] = max(state["max_active"], state["active"])
|
||||
|
||||
def leave():
|
||||
with lock:
|
||||
state["active"] -= 1
|
||||
|
||||
return state, enter, leave
|
||||
|
||||
|
||||
# Concurrency tests are `async def` (pyproject asyncio_mode="auto") and drive the
|
||||
# ASGI app through httpx.ASGITransport + AsyncClient + asyncio.gather, NOT starlette
|
||||
# TestClient + ThreadPoolExecutor: the job lock is an asyncio.Lock acquired in the
|
||||
# async handler, and TestClient's portal-thread dispatch deadlocks against it (same
|
||||
# reason test_notes_fail_closed_auth.py uses ASGITransport). asyncio.gather runs both
|
||||
# requests on the test's own loop.
|
||||
def _async_client(app):
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t")
|
||||
|
||||
import routes.personal_routes as personal_routes
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import require_user
|
||||
|
||||
|
||||
class _FakeRag:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
|
||||
def index_personal_documents(self, directory, owner=None):
|
||||
self._record["index_thread"] = threading.get_ident()
|
||||
return {"success": True, "indexed_count": 3, "failed_count": 0}
|
||||
|
||||
def _split_into_chunks(self, text, chunk_size=500):
|
||||
return [text]
|
||||
|
||||
def add_document(self, chunk, metadata):
|
||||
self._record["add_document_thread"] = threading.get_ident()
|
||||
return True
|
||||
|
||||
def delete_by_source(self, filepath):
|
||||
self._record["delete_thread"] = threading.get_ident()
|
||||
return 1
|
||||
|
||||
|
||||
class _FakeDocsManager:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
self.index = []
|
||||
|
||||
def add_directory(self, directory, *, index=True, owner=None):
|
||||
self._record["bookkeeping_thread"] = threading.get_ident()
|
||||
self._record["bookkeeping_index_flag"] = index
|
||||
|
||||
def exclude_file(self, filepath):
|
||||
self._record["exclude_thread"] = threading.get_ident()
|
||||
|
||||
|
||||
def _build_app(tmp_path, monkeypatch, record):
|
||||
monkeypatch.setattr(personal_routes, "PERSONAL_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(personal_routes, "get_rag_manager", lambda: _FakeRag(record))
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
personal_routes.setup_personal_routes(_FakeDocsManager(record), None, True)
|
||||
)
|
||||
app.dependency_overrides[require_user] = lambda: "tester"
|
||||
app.dependency_overrides[require_admin] = lambda: None
|
||||
|
||||
@app.get("/loop-thread")
|
||||
async def loop_thread_probe():
|
||||
return {"thread": threading.get_ident()}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_indexing_runs_off_the_event_loop(tmp_path, monkeypatch):
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
target = tmp_path / "docs"
|
||||
target.mkdir()
|
||||
|
||||
# Context-manager client: one portal/event loop serves both requests, so
|
||||
# the probe and the POST are guaranteed to see the same loop thread.
|
||||
with TestClient(app) as client:
|
||||
loop_thread = client.get("/loop-thread").json()["thread"]
|
||||
resp = client.post(
|
||||
"/api/personal/add_directory", json={"directory": str(target)}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert record["index_thread"] != loop_thread, (
|
||||
"index_personal_documents ran on the event loop thread — every other "
|
||||
"request queues behind the indexing job (#5558)"
|
||||
)
|
||||
assert record["bookkeeping_thread"] != loop_thread, (
|
||||
"personal_docs_manager.add_directory (refresh_index) ran on the event "
|
||||
"loop thread"
|
||||
)
|
||||
|
||||
|
||||
def test_response_and_bookkeeping_unchanged(tmp_path, monkeypatch):
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
target = tmp_path / "docs"
|
||||
target.mkdir()
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["indexed_count"] == 3
|
||||
assert body["failed_count"] == 0
|
||||
assert body["directory"] == os.path.realpath(str(target))
|
||||
assert record["bookkeeping_index_flag"] is False
|
||||
|
||||
|
||||
async def test_concurrent_add_directory_requests_serialize_indexing(tmp_path, monkeypatch):
|
||||
"""Off-loop execution must not mean parallel index jobs: concurrent
|
||||
requests would race PersonalDocsManager's unsynchronized list mutations
|
||||
and file writes (save_directories/_save_excluded are plain open('w'))."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.2); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
for name in ("docs_a", "docs_b"):
|
||||
(tmp_path / name).mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_b")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} index jobs ran in parallel — concurrent "
|
||||
"add_directory requests must serialize"
|
||||
)
|
||||
|
||||
|
||||
def test_failed_indexing_still_returns_500(tmp_path, monkeypatch):
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
target = tmp_path / "docs"
|
||||
target.mkdir()
|
||||
|
||||
def _fail(directory, owner=None):
|
||||
return {"success": False, "message": "boom"}
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", staticmethod(_fail))
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
|
||||
assert resp.status_code == 500
|
||||
assert "boom" in resp.json()["detail"]
|
||||
|
||||
|
||||
async def test_add_and_remove_serialize(tmp_path, monkeypatch):
|
||||
"""#5634: remove must hold the SAME job lock as add. Otherwise a remove
|
||||
running while an add job is in flight races PersonalDocsManager's
|
||||
unsynchronized list/index mutations — the inconsistent state the PR's
|
||||
'add/remove are serialized' guarantee claims to prevent."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_remove(self, directory):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeDocsManager, "remove_directory", _slow_remove, raising=False)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
(tmp_path / "docs_b").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.delete("/api/personal/remove_directory", params={"directory": str(tmp_path / "docs_b")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/remove critical sections overlapped — "
|
||||
"remove must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_add_and_upload_serialize(tmp_path, monkeypatch):
|
||||
"""#5634 follow-up: POST /upload writes chunks into the vector store and then
|
||||
calls personal_docs_manager.add_directory — the same vector/tracking state
|
||||
add_directory mutates. It must hold the SAME job lock, or an upload landing
|
||||
mid-add interleaves two writers over unsynchronized state."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_add_document(self, chunk, metadata):
|
||||
self._record["add_document_thread"] = threading.get_ident()
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeRag, "add_document", _slow_add_document)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/upload", files={"files": ("a.txt", b"hello world", "text/plain")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
# The test coroutine runs on the event loop, so this IS the loop thread.
|
||||
assert record["add_document_thread"] != threading.get_ident(), (
|
||||
"rag.add_document ran on the event loop thread — chunk writes block "
|
||||
"every other request for the duration of the upload"
|
||||
)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/upload critical sections overlapped — "
|
||||
"upload must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_processes_each_payload_before_reading_the_next(tmp_path, monkeypatch):
|
||||
"""A multi-file upload must retain at most one capped payload at a time."""
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
|
||||
reads = []
|
||||
original_read = StarletteUploadFile.read
|
||||
|
||||
async def _recording_read(upload, size=-1):
|
||||
reads.append(upload.filename)
|
||||
return await original_read(upload, size)
|
||||
|
||||
def _record_first_index(self, chunk, metadata):
|
||||
self._record.setdefault("reads_at_first_index", len(reads))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(StarletteUploadFile, "read", _recording_read)
|
||||
monkeypatch.setattr(_FakeRag, "add_document", _record_first_index)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
|
||||
|
||||
files = [
|
||||
("files", ("a.txt", b"alpha", "text/plain")),
|
||||
("files", ("b.txt", b"bravo", "text/plain")),
|
||||
("files", ("c.txt", b"charlie", "text/plain")),
|
||||
]
|
||||
async with _async_client(app) as ac:
|
||||
response = await ac.post("/api/personal/upload", files=files)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["uploaded"] == ["a.txt", "b.txt", "c.txt"]
|
||||
assert reads == ["a.txt", "b.txt", "c.txt"]
|
||||
assert record["reads_at_first_index"] == 1, (
|
||||
"all upload bodies were retained before worker processing began"
|
||||
)
|
||||
|
||||
|
||||
async def test_add_and_delete_file_serialize(tmp_path, monkeypatch):
|
||||
"""#5634 follow-up: DELETE /file removes chunks from the vector store and
|
||||
calls personal_docs_manager.exclude_file. Both mutate state add_directory
|
||||
also touches, so the delete must hold the SAME job lock as add."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_delete(self, filepath):
|
||||
self._record["delete_thread"] = threading.get_ident()
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeRag, "delete_by_source", _slow_delete)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
doomed = tmp_path / "doomed.txt"
|
||||
doomed.write_text("bye")
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.delete("/api/personal/file", params={"filepath": str(doomed)}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert record["delete_thread"] != threading.get_ident(), (
|
||||
"rag.delete_by_source ran on the event loop thread"
|
||||
)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/delete critical sections overlapped — "
|
||||
"delete must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_reload_serializes_with_add(tmp_path, monkeypatch):
|
||||
"""#5634: POST /reload rebuilds the index via refresh_index(); it must hold
|
||||
the same job lock so it cannot race an in-flight add job."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_refresh(self):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeDocsManager, "refresh_index", _slow_refresh, raising=False)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/reload"),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/reload critical sections overlapped — "
|
||||
"reload must hold the same index job lock as add"
|
||||
)
|
||||
@@ -314,17 +314,21 @@ class TestComputeFinalMetrics:
|
||||
def test_tool_events_included(self):
|
||||
events = [{"tool": "bash", "duration": 1.0}]
|
||||
texts = ["round 1 text"]
|
||||
models = ["round-1-model"]
|
||||
m = _compute_final_metrics(**self._base_args(
|
||||
tool_events=events,
|
||||
round_texts=texts,
|
||||
round_models=models,
|
||||
))
|
||||
assert m["tool_events"] == events
|
||||
assert m["round_texts"] == texts
|
||||
assert m["round_models"] == models
|
||||
|
||||
def test_no_tool_events_excluded(self):
|
||||
m = _compute_final_metrics(**self._base_args(tool_events=[], round_texts=[]))
|
||||
assert "tool_events" not in m
|
||||
assert "round_texts" not in m
|
||||
assert "round_models" not in m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Saved Agent rounds must render and bill with actual per-round provenance."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_SOURCE = (
|
||||
Path(__file__).resolve().parents[1] / "static" / "js" / "chatRenderer.js"
|
||||
).read_text(encoding="utf-8")
|
||||
_CHAT_SOURCE = (
|
||||
Path(__file__).resolve().parents[1] / "static" / "js" / "chat.js"
|
||||
).read_text(encoding="utf-8")
|
||||
_SLASH_SOURCE = (
|
||||
Path(__file__).resolve().parents[1] / "static" / "js" / "slashCommands.js"
|
||||
).read_text(encoding="utf-8")
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _function_source(name):
|
||||
match = re.search(
|
||||
rf"^(?:export )?function {name}\(.*?^\}}",
|
||||
_SOURCE,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
assert match, f"{name} not found"
|
||||
return match.group(0).replace("export function", "function", 1)
|
||||
|
||||
|
||||
def _run_node(source):
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=source,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
def test_saved_agent_rounds_prefer_round_model_provenance():
|
||||
assert "const roundModels = metadata.round_models || [];" in _SOURCE
|
||||
assert "const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;" in _SOURCE
|
||||
assert "Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1" in _SOURCE
|
||||
assert "const roundEndpointIds = metadata.round_endpoint_ids || [];" in _SOURCE
|
||||
assert "const roundEndpointLabels = metadata.round_endpoint_labels || [];" in _SOURCE
|
||||
assert "r < roundEndpointIds.length" in _SOURCE
|
||||
assert "r < roundEndpointLabels.length" in _SOURCE
|
||||
assert "roundEndpointIds[r] || pair.actualEndpointId" not in _SOURCE
|
||||
|
||||
|
||||
def test_metrics_cost_uses_actual_fallback_endpoint_classification():
|
||||
assert "metrics.endpoint_cost_tracked" in _SOURCE
|
||||
assert "endpointCostTracked === false" in _SOURCE
|
||||
assert "endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)" in _SOURCE
|
||||
assert "Array.isArray(metrics.usage_buckets)" in _SOURCE
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_agent_usage_buckets_sum_only_billable_answering_routes():
|
||||
source = "\n".join([
|
||||
"let currentUrl = '';",
|
||||
"function _currentEndpointUrl() { return currentUrl; }",
|
||||
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
"const paidSelected = {usage_buckets: [",
|
||||
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true},",
|
||||
" {model: 'local-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: false},",
|
||||
"]};",
|
||||
"const localSelected = {usage_buckets: [",
|
||||
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: false},",
|
||||
" {model: 'paid-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: true},",
|
||||
"]};",
|
||||
"currentUrl = 'local';",
|
||||
"const paidToLocal = _metricsBillableCost(paidSelected, 'final', 300, 30);",
|
||||
"currentUrl = 'paid';",
|
||||
"const localToPaid = _metricsBillableCost(localSelected, 'final', 300, 30);",
|
||||
"console.log(JSON.stringify({paidToLocal, localToPaid}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"paidToLocal": 0.11, "localToPaid": 0.22}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_force_answer_synthesis_segment_is_included_in_fallback_cost():
|
||||
source = "\n".join([
|
||||
"function _currentEndpointUrl() { return 'local-selected'; }",
|
||||
"function isCostTrackedEndpoint() { return false; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
"const metrics = {usage_buckets: [",
|
||||
" {round: 6, model: 'paid-fallback', input_tokens: 100, output_tokens: 0, endpoint_cost_tracked: true},",
|
||||
" {round: 6, model: 'paid-fallback', input_tokens: 80, output_tokens: 20, endpoint_cost_tracked: true},",
|
||||
"]};",
|
||||
"console.log(JSON.stringify({cost: _metricsBillableCost(metrics, 'paid-fallback', 180, 20)}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"cost": 0.2}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_repeated_live_metrics_render_records_session_cost_once():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const state = {};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
|
||||
"function updateSessionCostUI() {}",
|
||||
"function _currentEndpointUrl() { return 'local'; }",
|
||||
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
_function_source("recordSessionMetricsCost"),
|
||||
"const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};",
|
||||
"recordSessionMetricsCost(metrics);",
|
||||
"recordSessionMetricsCost(metrics);",
|
||||
"console.log(JSON.stringify({cost: JSON.parse(state[_COST_KEY]).session, recorded: metrics._costRecorded}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"cost": 0.11, "recorded": True}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_replayed_metrics_use_run_identity_for_durable_cost_deduplication():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
|
||||
"const _MAX_COST_RUNS_PER_SESSION = 256;",
|
||||
"const state = {};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
|
||||
"function updateSessionCostUI() {}",
|
||||
"function _currentEndpointUrl() { return 'local'; }",
|
||||
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
_function_source("recordSessionMetricsCost"),
|
||||
_function_source("getSessionCost"),
|
||||
"const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};",
|
||||
"const replayedObject = {...firstObject};",
|
||||
"recordSessionMetricsCost(firstObject);",
|
||||
"recordSessionMetricsCost(replayedObject);",
|
||||
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {"cost": 0.11, "runs": {"run-1": 0.11}}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_run_cost_ledger_sums_segments_and_updates_repeated_segment_metrics():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
|
||||
"const _MAX_COST_RUNS_PER_SESSION = 256;",
|
||||
"const state = {};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
|
||||
"function updateSessionCostUI() {}",
|
||||
"function _currentEndpointUrl() { return 'paid'; }",
|
||||
"function isCostTrackedEndpoint() { return true; }",
|
||||
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
|
||||
_function_source("_billableCost"),
|
||||
_function_source("_metricsBillableCost"),
|
||||
_function_source("recordSessionMetricsCost"),
|
||||
_function_source("getSessionCost"),
|
||||
"recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});",
|
||||
"recordSessionMetricsCost({model: 'student', input_tokens: 120, output_tokens: 20, _costRecordId: 'run:primary'});",
|
||||
"recordSessionMetricsCost({model: 'teacher', input_tokens: 200, output_tokens: 30, _costRecordId: 'run:teacher'});",
|
||||
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"cost": 0.37,
|
||||
"runs": {"run:primary": 0.14, "run:teacher": 0.23},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_local_selected_endpoint_does_not_erase_paid_fallback_ledger():
|
||||
source = "\n".join([
|
||||
"const _COST_KEY = 'ody-session-cost';",
|
||||
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
|
||||
"const state = {'ody-session-cost': JSON.stringify({session: 0.125})};",
|
||||
"const localStorage = {",
|
||||
" getItem(key) { return state[key] || null; },",
|
||||
" setItem(key, value) { state[key] = value; },",
|
||||
"};",
|
||||
"const badge = {style: {}, textContent: ''};",
|
||||
"const document = {getElementById() { return badge; }};",
|
||||
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};",
|
||||
_function_source("getSessionCost"),
|
||||
_function_source("updateSessionCostUI"),
|
||||
"updateSessionCostUI();",
|
||||
"console.log(JSON.stringify({stored: JSON.parse(state[_COST_KEY]).session, display: badge.style.display, text: badge.textContent}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"stored": 0.125,
|
||||
"display": "",
|
||||
"text": "$0.125",
|
||||
}
|
||||
|
||||
|
||||
def test_live_and_resumed_terminal_events_apply_usage_metrics_before_reload():
|
||||
assert "metrics = json.data || metrics;" in _CHAT_SOURCE
|
||||
assert "displayMetrics(terminalMetricsTarget, metrics);" in _CHAT_SOURCE
|
||||
assert "metricsData = json.data || metricsData;" in _CHAT_SOURCE
|
||||
assert "displayMetrics(holder, metricsData);" in _CHAT_SOURCE
|
||||
assert "json.type === 'agent_terminal' || json.type === 'chat_terminal'" in _CHAT_SOURCE
|
||||
assert "chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);" in _CHAT_SOURCE
|
||||
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId);" in _CHAT_SOURCE
|
||||
assert "metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);" in _CHAT_SOURCE
|
||||
assert "bgTerminal.status = 'completed';" in _CHAT_SOURCE
|
||||
|
||||
|
||||
def test_usage_command_does_not_hide_existing_fallback_cost_for_local_selection():
|
||||
assert "const cost = chatRenderer.getSessionCost" in _SLASH_SOURCE
|
||||
assert "const cost = costTracked && chatRenderer.getSessionCost" not in _SLASH_SOURCE
|
||||
@@ -0,0 +1,538 @@
|
||||
"""Default calendar creation belongs to the caller's transaction.
|
||||
|
||||
Before this regression, ``_ensure_default_calendar`` committed independently.
|
||||
If event persistence then failed, the event rolled back but a new ``Personal``
|
||||
calendar remained (``calendar_count=1``, ``event_count=0``).
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.helpers.import_state import clear_fake_database_modules
|
||||
|
||||
clear_fake_database_modules()
|
||||
|
||||
import core.database as cdb # noqa: E402
|
||||
import routes.calendar_routes as calendar_routes # noqa: E402
|
||||
from core.database import CalendarCal, CalendarEvent # noqa: E402
|
||||
from routes.calendar_routes import EventCreate # noqa: E402
|
||||
from routes.calendar_routes import ( # noqa: E402
|
||||
_default_calendar_id,
|
||||
_ensure_default_calendar,
|
||||
)
|
||||
|
||||
|
||||
class _RejectEventCommit(Session):
|
||||
"""Reproduce an event commit failure after default-calendar creation."""
|
||||
|
||||
def commit(self):
|
||||
if any(isinstance(row, CalendarEvent) for row in self.new):
|
||||
raise RuntimeError("commit guard rejected event commit")
|
||||
return super().commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(tmp_path, monkeypatch):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'calendar.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(
|
||||
bind=engine,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
class_=_RejectEventCommit,
|
||||
)
|
||||
monkeypatch.setattr(cdb, "SessionLocal", factory)
|
||||
monkeypatch.setattr(calendar_routes, "SessionLocal", factory)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _request():
|
||||
return SimpleNamespace(state=SimpleNamespace(current_user="alice"))
|
||||
|
||||
|
||||
def _endpoint(method, suffix):
|
||||
router = calendar_routes.setup_calendar_routes()
|
||||
for route in router.routes:
|
||||
if route.path.endswith(suffix) and method in route.methods:
|
||||
return route.endpoint
|
||||
raise RuntimeError(f"{method} *{suffix} not found")
|
||||
|
||||
|
||||
def _counts(factory):
|
||||
db = factory()
|
||||
try:
|
||||
return db.query(CalendarCal).count(), db.query(CalendarEvent).count()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def test_route_event_failure_rolls_back_new_default_calendar(session_factory):
|
||||
create_event = _endpoint("POST", "/events")
|
||||
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
await create_event(
|
||||
_request(),
|
||||
EventCreate(summary="Planning", dtstart="2126-07-20T09:00:00Z"),
|
||||
)
|
||||
|
||||
assert caught.value.status_code == 500
|
||||
assert _counts(session_factory) == (0, 0)
|
||||
|
||||
|
||||
async def test_route_event_validation_failure_rolls_back_new_default_calendar(
|
||||
session_factory,
|
||||
):
|
||||
create_event = _endpoint("POST", "/events")
|
||||
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
await create_event(
|
||||
_request(),
|
||||
EventCreate(summary="Planning", dtstart="not-a-datetime"),
|
||||
)
|
||||
|
||||
assert caught.value.status_code == 500
|
||||
assert _counts(session_factory) == (0, 0)
|
||||
|
||||
|
||||
async def test_tool_event_failure_rolls_back_new_default_calendar(session_factory):
|
||||
from src.tools.calendar import do_manage_calendar
|
||||
|
||||
result = await do_manage_calendar(
|
||||
json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "Planning",
|
||||
"dtstart": "2126-07-20T09:00:00Z",
|
||||
}),
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "commit guard rejected event commit" in result["error"]
|
||||
assert _counts(session_factory) == (0, 0)
|
||||
|
||||
|
||||
async def test_tool_event_validation_failure_rolls_back_new_default_calendar(
|
||||
session_factory,
|
||||
):
|
||||
from src.tools.calendar import do_manage_calendar
|
||||
|
||||
result = await do_manage_calendar(
|
||||
json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "Planning",
|
||||
"dtstart": "not-a-datetime",
|
||||
}),
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Could not parse dtstart" in result["error"]
|
||||
assert _counts(session_factory) == (0, 0)
|
||||
|
||||
|
||||
async def test_route_list_calendars_persists_lazy_default(session_factory):
|
||||
list_calendars = _endpoint("GET", "/calendars")
|
||||
|
||||
result = await list_calendars(_request())
|
||||
|
||||
assert [calendar["name"] for calendar in result["calendars"]] == ["Personal"]
|
||||
assert _counts(session_factory) == (1, 0)
|
||||
|
||||
|
||||
async def test_tool_list_calendars_persists_lazy_default(session_factory):
|
||||
from src.tools.calendar import do_manage_calendar
|
||||
|
||||
result = await do_manage_calendar(
|
||||
json.dumps({"action": "list_calendars"}),
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert [calendar["name"] for calendar in result["calendars"]] == ["Personal"]
|
||||
assert _counts(session_factory) == (1, 0)
|
||||
|
||||
|
||||
def test_repeated_rename_and_reuse_uses_stable_fallback_ids(tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'renamed-calendar.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
db = factory()
|
||||
try:
|
||||
first = _ensure_default_calendar(db, "alice")
|
||||
assert first.id == _default_calendar_id("alice")
|
||||
db.commit()
|
||||
|
||||
# The supported user-rename migration changes owner columns while
|
||||
# deliberately preserving durable row identifiers.
|
||||
first.owner = "bob"
|
||||
db.commit()
|
||||
|
||||
second = _ensure_default_calendar(db, "alice")
|
||||
assert second.id == _default_calendar_id("alice", 1)
|
||||
db.commit()
|
||||
|
||||
# Repeating the same lifecycle must advance deterministically instead
|
||||
# of failing or choosing a random identifier.
|
||||
second.owner = "carol"
|
||||
db.commit()
|
||||
|
||||
third = _ensure_default_calendar(db, "alice")
|
||||
assert third.id == _default_calendar_id("alice", 2)
|
||||
db.commit()
|
||||
|
||||
rows = db.query(CalendarCal).order_by(CalendarCal.owner).all()
|
||||
assert [(row.owner, row.id) for row in rows] == [
|
||||
("alice", _default_calendar_id("alice", 2)),
|
||||
("bob", _default_calendar_id("alice")),
|
||||
("carol", _default_calendar_id("alice", 1)),
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_concurrent_first_use(tmp_path, occupied_owner=None):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'concurrent-calendar.db'}",
|
||||
connect_args={"check_same_thread": False, "timeout": 10},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
expected_collision_index = 0
|
||||
if occupied_owner is not None:
|
||||
seed = factory()
|
||||
try:
|
||||
seed.add(CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner=occupied_owner,
|
||||
name="Personal",
|
||||
source="local",
|
||||
))
|
||||
seed.commit()
|
||||
expected_collision_index = 1
|
||||
finally:
|
||||
seed.close()
|
||||
first_staged = threading.Event()
|
||||
second_selected = threading.Event()
|
||||
errors = []
|
||||
|
||||
@event.listens_for(engine, "after_cursor_execute")
|
||||
def observe_second_gap(conn, cursor, statement, parameters, context, executemany):
|
||||
if (
|
||||
threading.current_thread().name == "calendar-worker-second"
|
||||
and statement.lstrip().upper().startswith("SELECT")
|
||||
and "FROM calendars" in statement
|
||||
):
|
||||
second_selected.set()
|
||||
|
||||
def create_default(worker, hold=False):
|
||||
db = factory()
|
||||
try:
|
||||
if not hold:
|
||||
assert first_staged.wait(5)
|
||||
cal = _ensure_default_calendar(db, "alice")
|
||||
start = datetime(2126, 7, 20, 9 if hold else 10)
|
||||
db.add(CalendarEvent(
|
||||
uid=worker,
|
||||
calendar_id=cal.id,
|
||||
summary=f"Event {worker}",
|
||||
dtstart=start,
|
||||
dtend=start + timedelta(hours=1),
|
||||
))
|
||||
if hold:
|
||||
first_staged.set()
|
||||
# The second session has observed the uncommitted gap before
|
||||
# this transaction releases its writer reservation.
|
||||
assert second_selected.wait(5)
|
||||
db.commit()
|
||||
assert cal.id == _default_calendar_id("alice", expected_collision_index)
|
||||
except BaseException as exc: # pragma: no cover - asserted below
|
||||
errors.append((worker, exc))
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
first = threading.Thread(
|
||||
target=create_default,
|
||||
args=("first", True),
|
||||
name="calendar-worker-first",
|
||||
)
|
||||
second = threading.Thread(
|
||||
target=create_default,
|
||||
args=("second",),
|
||||
name="calendar-worker-second",
|
||||
)
|
||||
first.start()
|
||||
second.start()
|
||||
first.join(10)
|
||||
second.join(10)
|
||||
|
||||
try:
|
||||
assert not first.is_alive() and not second.is_alive()
|
||||
assert errors == []
|
||||
db = factory()
|
||||
try:
|
||||
rows = db.query(CalendarCal).filter(CalendarCal.owner == "alice").all()
|
||||
assert [(row.id, row.name) for row in rows] == [
|
||||
(_default_calendar_id("alice", expected_collision_index), "Personal")
|
||||
]
|
||||
assert db.query(CalendarEvent).count() == 2
|
||||
if occupied_owner is not None:
|
||||
occupied = db.query(CalendarCal).filter(
|
||||
CalendarCal.id == _default_calendar_id("alice"),
|
||||
).one()
|
||||
assert occupied.owner == occupied_owner
|
||||
finally:
|
||||
db.close()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_concurrent_first_use_creates_one_sqlite_default(tmp_path):
|
||||
_assert_concurrent_first_use(tmp_path)
|
||||
|
||||
|
||||
def test_concurrent_first_use_after_rename_creates_one_fallback_default(tmp_path):
|
||||
_assert_concurrent_first_use(tmp_path, occupied_owner="bob")
|
||||
|
||||
|
||||
def test_sqlite_default_stays_in_callers_transaction(session_factory):
|
||||
db = session_factory()
|
||||
try:
|
||||
cal = _ensure_default_calendar(db, "rollback-owner")
|
||||
assert cal.id == _default_calendar_id("rollback-owner")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
verify = session_factory()
|
||||
try:
|
||||
assert (
|
||||
verify.query(CalendarCal)
|
||||
.filter(CalendarCal.owner == "rollback-owner")
|
||||
.count()
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
verify.close()
|
||||
|
||||
|
||||
def test_sqlite_fallback_default_stays_in_callers_transaction(session_factory):
|
||||
seed = session_factory()
|
||||
try:
|
||||
seed.add(CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner="bob",
|
||||
name="Personal",
|
||||
source="local",
|
||||
))
|
||||
seed.commit()
|
||||
finally:
|
||||
seed.close()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
cal = _ensure_default_calendar(db, "alice")
|
||||
assert cal.id == _default_calendar_id("alice", 1)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
verify = session_factory()
|
||||
try:
|
||||
assert verify.query(CalendarCal).filter(CalendarCal.owner == "alice").count() == 0
|
||||
assert verify.query(CalendarCal).filter(CalendarCal.owner == "bob").count() == 1
|
||||
finally:
|
||||
verify.close()
|
||||
|
||||
|
||||
class _FakeDialect:
|
||||
name = "postgresql"
|
||||
|
||||
|
||||
class _FakeBind:
|
||||
dialect = _FakeDialect()
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def filter(self, *conditions):
|
||||
return self
|
||||
|
||||
def with_for_update(self):
|
||||
self.session.locking_read = True
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
self.session.query_count += 1
|
||||
if self.session.query_count == 1:
|
||||
return None
|
||||
return self.session.winner
|
||||
|
||||
|
||||
class _GenericRaceSession:
|
||||
"""Minimal non-SQLite session that loses the deterministic-ID race."""
|
||||
|
||||
def __init__(self):
|
||||
self.query_count = 0
|
||||
self.nested_entries = 0
|
||||
self.locking_read = False
|
||||
self.candidate = None
|
||||
self.winner = CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner="alice",
|
||||
name="Personal",
|
||||
source="local",
|
||||
)
|
||||
|
||||
def get_bind(self):
|
||||
return _FakeBind()
|
||||
|
||||
def query(self, model):
|
||||
assert model is CalendarCal
|
||||
return _FakeQuery(self)
|
||||
|
||||
@contextmanager
|
||||
def begin_nested(self):
|
||||
self.nested_entries += 1
|
||||
yield
|
||||
|
||||
def add(self, row):
|
||||
self.candidate = row
|
||||
|
||||
def flush(self):
|
||||
raise IntegrityError("insert", {}, RuntimeError("duplicate primary key"))
|
||||
|
||||
|
||||
def test_generic_backend_lost_race_recovers_inside_savepoint():
|
||||
db = _GenericRaceSession()
|
||||
|
||||
winner = _ensure_default_calendar(db, "alice")
|
||||
|
||||
assert winner is db.winner
|
||||
assert db.nested_entries == 1
|
||||
assert db.locking_read is True
|
||||
assert db.candidate.id == db.winner.id
|
||||
|
||||
|
||||
def test_generic_backend_unattributed_integrity_error_is_not_retried():
|
||||
db = _GenericRaceSession()
|
||||
db.winner = None
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
_ensure_default_calendar(db, "alice")
|
||||
|
||||
assert db.nested_entries == 1
|
||||
|
||||
|
||||
class _GenericRenamedSlotSession(_GenericRaceSession):
|
||||
"""A different owner occupies slot zero; slot one remains available."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.candidates = []
|
||||
self.winner = CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner="bob",
|
||||
name="Personal",
|
||||
source="local",
|
||||
)
|
||||
|
||||
def add(self, row):
|
||||
self.candidate = row
|
||||
self.candidates.append(row)
|
||||
|
||||
def flush(self):
|
||||
if len(self.candidates) == 1:
|
||||
raise IntegrityError("insert", {}, RuntimeError("duplicate primary key"))
|
||||
|
||||
|
||||
def test_generic_backend_renamed_slot_advances_inside_savepoint():
|
||||
db = _GenericRenamedSlotSession()
|
||||
|
||||
fallback = _ensure_default_calendar(db, "alice")
|
||||
|
||||
assert fallback is db.candidates[-1]
|
||||
assert fallback.id == _default_calendar_id("alice", 1)
|
||||
assert fallback.owner == "alice"
|
||||
assert db.nested_entries == 2
|
||||
assert db.locking_read is True
|
||||
assert db.winner.owner == "bob"
|
||||
|
||||
|
||||
def test_generic_backend_fallback_keeps_outer_transaction_usable(tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'generic-savepoint-calendar.db'}",
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
# SQLite supplies a lightweight local SQL executor here; changing only the
|
||||
# dispatch name exercises the real Session/savepoint branch used by
|
||||
# PostgreSQL-style backends without pretending to validate their dialect.
|
||||
engine.dialect.name = "postgresql"
|
||||
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
seed = factory()
|
||||
try:
|
||||
seed.add(CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner="bob",
|
||||
name="Personal",
|
||||
source="local",
|
||||
))
|
||||
seed.commit()
|
||||
finally:
|
||||
seed.close()
|
||||
|
||||
db = factory()
|
||||
try:
|
||||
cal = _ensure_default_calendar(db, "alice")
|
||||
start = datetime(2126, 7, 20, 9)
|
||||
db.add(CalendarEvent(
|
||||
uid="after-fallback",
|
||||
calendar_id=cal.id,
|
||||
summary="Atomic",
|
||||
dtstart=start,
|
||||
dtend=start + timedelta(hours=1),
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
verify = factory()
|
||||
try:
|
||||
assert [
|
||||
(row.owner, row.id)
|
||||
for row in verify.query(CalendarCal).order_by(CalendarCal.owner).all()
|
||||
] == [
|
||||
("alice", _default_calendar_id("alice", 1)),
|
||||
("bob", _default_calendar_id("alice")),
|
||||
]
|
||||
assert verify.query(CalendarEvent).count() == 1
|
||||
finally:
|
||||
verify.close()
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Execute the round-aware live model-provenance state helper under Node."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_MODULE = (_REPO / "static" / "js" / "chatModelProvenance.js").as_uri()
|
||||
|
||||
|
||||
def test_round_two_fallback_then_provider_alias_does_not_relabel_round_one():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ applyModelRouteEventState }} from {json.dumps(_MODULE)};
|
||||
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
|
||||
const round2 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
|
||||
|
||||
const fallbackTarget = applyModelRouteEventState({{
|
||||
type: 'fallback', round: 2,
|
||||
selected_model: 'selected-model', answered_by: 'backup-model'
|
||||
}}, round1, round2, 'selected-model');
|
||||
const aliasTarget = applyModelRouteEventState({{
|
||||
type: 'model_actual', round: 2,
|
||||
requested_model: 'selected-model', model: 'provider-backup-alias'
|
||||
}}, round1, round2, 'selected-model');
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
fallbackIsRound2: fallbackTarget === round2,
|
||||
aliasIsRound2: aliasTarget === round2,
|
||||
round1,
|
||||
round2,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
state = json.loads(result.stdout)
|
||||
assert state == {
|
||||
"fallbackIsRound2": True,
|
||||
"aliasIsRound2": True,
|
||||
"round1": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "selected-model",
|
||||
},
|
||||
"round2": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "provider-backup-alias",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_next_round_and_final_metrics_preserve_each_agent_round_route():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{
|
||||
applyModelMetricsState,
|
||||
applyModelRouteEventState,
|
||||
inheritModelRouteState,
|
||||
}} from {json.dumps(_MODULE)};
|
||||
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
|
||||
const round2 = {{}};
|
||||
inheritModelRouteState(round1, round1, round2, 'selected-model');
|
||||
applyModelRouteEventState({{
|
||||
type: 'fallback', round: 2,
|
||||
selected_model: 'selected-model', answered_by: 'backup-model'
|
||||
}}, round1, round2, 'selected-model');
|
||||
applyModelRouteEventState({{
|
||||
type: 'model_actual', round: 2,
|
||||
requested_model: 'selected-model', model: 'provider-backup-alias'
|
||||
}}, round1, round2, 'selected-model');
|
||||
|
||||
const round3 = {{}};
|
||||
inheritModelRouteState(round1, round2, round3, 'selected-model');
|
||||
const metricsTarget = applyModelMetricsState({{
|
||||
requested_model: 'selected-model',
|
||||
model: 'provider-backup-alias',
|
||||
round_models: ['selected-model', 'provider-backup-alias', 'backup-model'],
|
||||
}}, round1, round3, 'selected-model');
|
||||
|
||||
console.log(JSON.stringify({{
|
||||
metricsIsRound3: metricsTarget === round3,
|
||||
round1,
|
||||
round2,
|
||||
round3,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"metricsIsRound3": True,
|
||||
"round1": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "selected-model",
|
||||
},
|
||||
"round2": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "provider-backup-alias",
|
||||
},
|
||||
"round3": {
|
||||
"_requestedModel": "selected-model",
|
||||
"_actualModel": "backup-model",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_same_model_fallback_preserves_distinct_endpoint_route_state():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ applyModelMetricsState, applyModelRouteEventState }} from {json.dumps(_MODULE)};
|
||||
const holder = {{ _requestedModel: 'same-model', _actualModel: 'same-model' }};
|
||||
applyModelRouteEventState({{
|
||||
type: 'fallback',
|
||||
selected_model: 'same-model', answered_by: 'same-model',
|
||||
selected_endpoint_id: 'account-one', selected_endpoint_label: 'Account one',
|
||||
answered_by_endpoint_id: 'account-two', answered_by_endpoint_label: 'Account two',
|
||||
}}, holder, null, 'same-model');
|
||||
applyModelMetricsState({{
|
||||
requested_model: 'same-model', model: 'same-model',
|
||||
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
|
||||
endpoint_id: 'account-two', endpoint_label: 'Account two',
|
||||
}}, holder, null, 'same-model');
|
||||
console.log(JSON.stringify(holder));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"_requestedModel": "same-model",
|
||||
"_actualModel": "same-model",
|
||||
"_requestedEndpointId": "account-one",
|
||||
"_requestedEndpointLabel": "Account one",
|
||||
"_actualEndpointId": "account-two",
|
||||
"_actualEndpointLabel": "Account two",
|
||||
}
|
||||
|
||||
|
||||
def test_metrics_preserve_explicitly_unknown_round_endpoint():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ applyModelMetricsState }} from {json.dumps(_MODULE)};
|
||||
const holder = {{
|
||||
_requestedModel: 'same-model',
|
||||
_actualModel: 'same-model',
|
||||
_requestedEndpointId: 'account-one',
|
||||
_requestedEndpointLabel: 'Account one',
|
||||
}};
|
||||
const roundHolder = {{}};
|
||||
applyModelMetricsState({{
|
||||
requested_model: 'same-model', model: 'same-model',
|
||||
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
|
||||
endpoint_id: 'account-two', endpoint_label: 'Account two',
|
||||
round_endpoint_ids: [null], round_endpoint_labels: [null],
|
||||
}}, holder, roundHolder, 'same-model');
|
||||
console.log(JSON.stringify(roundHolder));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"_requestedModel": "same-model",
|
||||
"_actualModel": "same-model",
|
||||
"_requestedEndpointId": "account-one",
|
||||
"_requestedEndpointLabel": "Account one",
|
||||
"_actualEndpointId": None,
|
||||
"_actualEndpointLabel": None,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Execute terminal stream-error classification under Node."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_MODULE = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
|
||||
|
||||
|
||||
def test_terminal_provider_errors_preserve_text_and_never_auto_retry():
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is not installed")
|
||||
|
||||
script = f"""
|
||||
import {{ createTerminalStreamError, isRecoverableStreamError }} from {json.dumps(_MODULE)};
|
||||
const stringError = createTerminalStreamError({{ status: 401, error: 'invalid key' }});
|
||||
const objectError = createTerminalStreamError({{ status: 404, error: {{ message: 'model missing' }} }});
|
||||
console.log(JSON.stringify({{
|
||||
stringMessage: stringError.message,
|
||||
objectMessage: objectError.message,
|
||||
terminalRecoverable: isRecoverableStreamError(stringError),
|
||||
eofRecoverable: isRecoverableStreamError(new Error('Stream closed before completion')),
|
||||
networkRecoverable: isRecoverableStreamError(new TypeError('fetch failed')),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"stringMessage": "invalid key",
|
||||
"objectMessage": "model missing",
|
||||
"terminalRecoverable": False,
|
||||
"eofRecoverable": True,
|
||||
"networkRecoverable": True,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Regression coverage for authoritative Python CI validation."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_WORKFLOW = (
|
||||
Path(__file__).resolve().parent.parent / ".github" / "workflows" / "ci.yml"
|
||||
)
|
||||
|
||||
|
||||
def _indented_block(text: str, heading: str, indent: int) -> str:
|
||||
pattern = re.compile(
|
||||
rf"(?ms)^{' ' * indent}{re.escape(heading)}:\n"
|
||||
rf"(?P<body>(?:(?:{' ' * (indent + 2)}.*|\s*)\n)*)"
|
||||
)
|
||||
match = pattern.search(text)
|
||||
assert match is not None, f"missing {heading!r} block"
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def test_ci_runs_on_integrated_dev_pushes():
|
||||
workflow = _WORKFLOW.read_text()
|
||||
push = _indented_block(workflow, "push", 2)
|
||||
|
||||
assert re.search(r"(?m)^ branches:\s*\[main,\s*dev\]\s*$", push)
|
||||
assert "paths-ignore:" not in push
|
||||
|
||||
|
||||
def test_python_tests_are_authoritative():
|
||||
workflow = _WORKFLOW.read_text()
|
||||
python_tests = _indented_block(workflow, "python-tests", 2)
|
||||
|
||||
assert "python -m pytest -q" in python_tests
|
||||
assert "continue-on-error:" not in python_tests
|
||||
@@ -81,6 +81,13 @@ def _make_stream_with_save(sink, chunks, *, hang_after=None):
|
||||
return gen()
|
||||
|
||||
|
||||
async def _collect_subscription(session_id, expected_run=None):
|
||||
return [
|
||||
event
|
||||
async for event in agent_runs.subscribe(session_id, expected_run)
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# agent_runs: detached-run semantics (what NORMAL chat/agent streams use)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -136,7 +143,7 @@ async def test_stop_cancels_detached_run_and_saves_partial_exactly_once():
|
||||
break
|
||||
await sub.aclose()
|
||||
|
||||
stopped = agent_runs.stop(session_id)
|
||||
stopped = agent_runs.stop(session_id, run.run_id)
|
||||
assert stopped is True
|
||||
|
||||
await run.task # propagates promptly — not stuck on the hung await
|
||||
@@ -165,6 +172,172 @@ async def test_normal_completion_saves_exactly_once_not_partial():
|
||||
assert sink.saves == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detached_run_identity_is_stable_for_replay_and_unique_per_run():
|
||||
session_id = "sess-detached-run-identity"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
|
||||
first = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["one"]))
|
||||
first_id = first.run_id
|
||||
assert agent_runs.get_run_id(session_id) == first_id
|
||||
await first.task
|
||||
assert agent_runs.get_run_id(session_id) == first_id
|
||||
|
||||
second = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["two"]))
|
||||
assert second.run_id != first_id
|
||||
assert agent_runs.get_run_id(session_id) == second.run_id
|
||||
await second.task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lazy_subscription_stays_bound_to_header_run_after_replacement():
|
||||
session_id = "sess-detached-lazy-subscription"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
|
||||
async def stream(label):
|
||||
yield f'data: {{"delta":"{label}"}}\n\n'
|
||||
|
||||
first = agent_runs.start(session_id, stream("first"))
|
||||
await first.task
|
||||
# StreamingResponse does not iterate its body until after construction.
|
||||
# Capture the same exact run object used for its identity header.
|
||||
lazy_body = agent_runs.subscribe(session_id, first)
|
||||
|
||||
second = agent_runs.start(session_id, stream("second"))
|
||||
await second.task
|
||||
|
||||
replayed = [event async for event in lazy_body]
|
||||
assert replayed == ['data: {"delta":"first"}\n\n']
|
||||
assert agent_runs.get_run_id(session_id) == second.run_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_run_identity_cannot_stop_replacement_run():
|
||||
session_id = "sess-detached-stale-stop"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def finished():
|
||||
yield 'data: {"delta":"old"}\n\n'
|
||||
|
||||
async def replacement():
|
||||
yield 'data: {"delta":"new"}\n\n'
|
||||
await release.wait()
|
||||
|
||||
first = agent_runs.start(session_id, finished())
|
||||
await first.task
|
||||
second = agent_runs.start(session_id, replacement())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert agent_runs.stop(session_id) is False
|
||||
assert agent_runs.stop(session_id, first.run_id) is False
|
||||
assert second.task is not None and not second.task.done()
|
||||
assert agent_runs.stop(session_id, second.run_id) is True
|
||||
await second.task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triple_replacement_closes_middle_subscriber_and_preserves_save_order():
|
||||
session_id = "sess-detached-triple-replacement"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
first_closing = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
third_started = asyncio.Event()
|
||||
|
||||
async def first_stream():
|
||||
try:
|
||||
yield 'data: {"delta":"first"}\n\n'
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
first_closing.set()
|
||||
await release_first.wait()
|
||||
|
||||
async def middle_stream():
|
||||
yield 'data: {"delta":"middle"}\n\n'
|
||||
|
||||
async def third_stream():
|
||||
third_started.set()
|
||||
yield 'data: {"delta":"third"}\n\n'
|
||||
|
||||
first = agent_runs.start(session_id, first_stream())
|
||||
while not first.buffer:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
middle = agent_runs.start(session_id, middle_stream())
|
||||
await first_closing.wait()
|
||||
assert middle.task is not None and not middle.task.done()
|
||||
|
||||
middle_events_task = asyncio.create_task(
|
||||
_collect_subscription(session_id, middle)
|
||||
)
|
||||
while not middle.subscribers:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
third = agent_runs.start(session_id, third_stream())
|
||||
|
||||
# The superseded middle response closes immediately even though its task
|
||||
# remains as the transitive barrier for the first run's partial save.
|
||||
assert await asyncio.wait_for(middle_events_task, timeout=1) == []
|
||||
assert middle.status == "stopped"
|
||||
assert middle.task is not None and not middle.task.done()
|
||||
assert not third_started.is_set()
|
||||
|
||||
release_first.set()
|
||||
await asyncio.wait_for(first.task, timeout=1)
|
||||
await asyncio.wait_for(middle.task, timeout=1)
|
||||
await asyncio.wait_for(third.task, timeout=1)
|
||||
|
||||
assert first.status == "stopped"
|
||||
assert middle.status == "stopped"
|
||||
assert third.status == "done"
|
||||
assert third_started.is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_replays_pinned_fallback_run_without_restarting_tools():
|
||||
session_id = "sess-detached-fallback-resume"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
release = asyncio.Event()
|
||||
tool_executions = 0
|
||||
fallback = 'data: {"type":"fallback","answered_by":"backup","candidate_index":1}\n\n'
|
||||
tool = 'data: {"type":"tool_output","tool":"bash","output":"ok"}\n\n'
|
||||
|
||||
async def pinned_run():
|
||||
nonlocal tool_executions
|
||||
yield fallback
|
||||
tool_executions += 1
|
||||
yield tool
|
||||
await release.wait()
|
||||
yield 'data: {"delta":"backup finished"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
run = agent_runs.start(session_id, pinned_run())
|
||||
first = agent_runs.subscribe(session_id)
|
||||
first_events = []
|
||||
async for event in first:
|
||||
first_events.append(event)
|
||||
if len(first_events) == 2:
|
||||
break
|
||||
await first.aclose()
|
||||
|
||||
assert run.status == "running"
|
||||
assert tool_executions == 1
|
||||
assert agent_runs._RUNS[session_id] is run
|
||||
|
||||
resumed_events = []
|
||||
resumed = agent_runs.subscribe(session_id)
|
||||
async for event in resumed:
|
||||
resumed_events.append(event)
|
||||
if len(resumed_events) == 2:
|
||||
release.set()
|
||||
await run.task
|
||||
|
||||
assert resumed_events[:2] == [fallback, tool]
|
||||
assert resumed_events[-1] == "data: [DONE]\n\n"
|
||||
assert tool_executions == 1
|
||||
assert agent_runs._RUNS[session_id] is run
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# chat_stream: Compare panes must NOT be detached, so the Stop button (closing
|
||||
# the SSE) cancels the upstream generator promptly — exercising the same
|
||||
|
||||
@@ -63,6 +63,23 @@ class TestSelfSummaryPrompt:
|
||||
|
||||
|
||||
class TestTrimForContext:
|
||||
def test_system_truncation_preserves_internal_route_metadata(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "persona\n\n" + ("agent prompt " * 2000),
|
||||
"_agent_injected": "merged_prompt",
|
||||
"_agent_base_message": {"role": "system", "content": "persona"},
|
||||
},
|
||||
{"role": "user", "content": "latest"},
|
||||
]
|
||||
|
||||
trimmed = trim_for_context(messages, context_length=1024, reserve_tokens=256)
|
||||
|
||||
system = next(message for message in trimmed if message.get("role") == "system")
|
||||
assert system["_agent_injected"] == "merged_prompt"
|
||||
assert system["_agent_base_message"] == {"role": "system", "content": "persona"}
|
||||
|
||||
def test_keeps_current_large_user_message_by_truncating(self):
|
||||
huge = "A" * 20000
|
||||
messages = [
|
||||
@@ -194,6 +211,50 @@ class TestMaybeCompactFourthMessage:
|
||||
assert len(result) == 3 and result[2] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deferred_compaction_persists_only_after_route_commit(monkeypatch):
|
||||
updates = []
|
||||
state = {}
|
||||
messages = [
|
||||
{"role": "system", "content": "system " * 100},
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
{"role": "user", "content": "five"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(cc, "get_context_length", lambda *args: 100)
|
||||
monkeypatch.setattr(cc, "resolve_endpoint", lambda *args, **kwargs: (None, None, None))
|
||||
|
||||
async def fake_summary(*args, **kwargs):
|
||||
return "route-specific summary"
|
||||
|
||||
monkeypatch.setattr(cc, "llm_call_async", fake_summary)
|
||||
monkeypatch.setattr(
|
||||
cc,
|
||||
"_update_session_history",
|
||||
lambda *args, **kwargs: updates.append((args, kwargs)),
|
||||
)
|
||||
|
||||
_compacted, _context, was_compacted = await cc.maybe_compact(
|
||||
object(),
|
||||
"https://candidate.example/v1",
|
||||
"candidate-model",
|
||||
messages,
|
||||
persist=False,
|
||||
compaction_state=state,
|
||||
)
|
||||
|
||||
assert was_compacted is True
|
||||
assert updates == []
|
||||
assert state["summary"] == "route-specific summary"
|
||||
assert cc.apply_compaction_state(object(), state) is True
|
||||
assert len(updates) == 1
|
||||
assert cc.apply_compaction_state(object(), state) is False
|
||||
assert len(updates) == 1
|
||||
|
||||
|
||||
class TestResearchPrimerPreserved:
|
||||
"""A research-spinoff primer (metadata research_spinoff_from) must never be
|
||||
trimmed away — it is the Discuss chat's sole knowledge base (drift fix)."""
|
||||
|
||||
@@ -723,7 +723,12 @@ def test_local_windows_download_pid_tracks_inner_bash_and_stop_kills_tree():
|
||||
routes_src = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
||||
running_src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbookRunning.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'printf \'%s\\\\n\' \\"$$\\" > {pp}' in routes_src
|
||||
# The Windows-local runner publishes Python's valid Win32 fallback before
|
||||
# allowing Git Bash to replace it with /proc/$$/winpid.
|
||||
assert "_windows_local_pid_record_line(pid_path, pid_ready_path)" in routes_src
|
||||
assert "/proc/$$/winpid" in routes_src
|
||||
assert "pid_ready_path.touch()" in routes_src
|
||||
assert '\\"$$\\" > {pp}' not in routes_src
|
||||
assert "function Stop-Tree([int]$Id)" in running_src
|
||||
assert "('ParentProcessId = ' + $Id)" in running_src
|
||||
assert "Stop-Tree ([int]$p)" in running_src
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Behavioral regression coverage for Windows-local Cookbook PID recording."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from routes.cookbook_routes import _windows_local_pid_record_line
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COOKBOOK_ROUTES = ROOT / "routes" / "cookbook_routes.py"
|
||||
|
||||
|
||||
def _fake_cat(tmp_path: Path, body: str) -> Path:
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
cat = fake_bin / "cat"
|
||||
cat.write_text("#!/bin/sh\n" + body + "\n", encoding="utf-8")
|
||||
cat.chmod(0o755)
|
||||
return fake_bin
|
||||
|
||||
|
||||
def _env_for(fake_bin: Path, **extra: str) -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "")
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
def _run_pid_line(
|
||||
pid_path: Path,
|
||||
ready_path: Path,
|
||||
fake_bin: Path,
|
||||
**extra_env: str,
|
||||
) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["bash", "-c", _windows_local_pid_record_line(pid_path, ready_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_env_for(fake_bin, **extra_env),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def test_windows_local_pid_line_records_numeric_winpid_after_fallback(tmp_path):
|
||||
pid_path = tmp_path / "serve.pid"
|
||||
ready_path = tmp_path / "serve.pid.ready"
|
||||
|
||||
pid_path.write_text("11111", encoding="utf-8")
|
||||
ready_path.touch()
|
||||
|
||||
cat_arg = tmp_path / "cat-arg.txt"
|
||||
fake_bin = _fake_cat(
|
||||
tmp_path,
|
||||
'printf "%s\\n" "$1" > "$FAKE_CAT_ARG"\n'
|
||||
'printf "%s\\n" "$FAKE_WINPID"',
|
||||
)
|
||||
|
||||
result = _run_pid_line(
|
||||
pid_path,
|
||||
ready_path,
|
||||
fake_bin,
|
||||
FAKE_CAT_ARG=str(cat_arg),
|
||||
FAKE_WINPID="42324",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert pid_path.read_text(encoding="utf-8").strip() == "42324"
|
||||
assert not ready_path.exists()
|
||||
|
||||
proc_path = cat_arg.read_text(encoding="utf-8").strip()
|
||||
parts = proc_path.strip("/").split("/")
|
||||
assert len(parts) == 3
|
||||
assert parts[0] == "proc"
|
||||
assert parts[1].isdigit()
|
||||
assert parts[2] == "winpid"
|
||||
|
||||
|
||||
def test_windows_local_pid_line_waits_for_python_fallback_before_replacing(tmp_path):
|
||||
pid_path = tmp_path / "serve.pid"
|
||||
ready_path = tmp_path / "serve.pid.ready"
|
||||
|
||||
fake_bin = _fake_cat(
|
||||
tmp_path,
|
||||
'printf "%s\\n" "$FAKE_WINPID"',
|
||||
)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
_windows_local_pid_record_line(pid_path, ready_path),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=_env_for(fake_bin, FAKE_WINPID="42324"),
|
||||
)
|
||||
|
||||
# The inner shell has started, but Python has not published its fallback yet.
|
||||
time.sleep(0.05)
|
||||
assert proc.poll() is None
|
||||
assert not pid_path.exists()
|
||||
|
||||
# Simulate the post-Popen Python publication order.
|
||||
pid_path.write_text("31100", encoding="utf-8")
|
||||
ready_path.touch()
|
||||
|
||||
stdout, stderr = proc.communicate(timeout=10)
|
||||
|
||||
assert proc.returncode == 0, stderr or stdout
|
||||
assert pid_path.read_text(encoding="utf-8").strip() == "42324"
|
||||
assert not ready_path.exists()
|
||||
|
||||
|
||||
def test_windows_local_pid_line_preserves_outer_pid_when_mapping_missing(tmp_path):
|
||||
pid_path = tmp_path / "serve.pid"
|
||||
ready_path = tmp_path / "serve.pid.ready"
|
||||
|
||||
pid_path.write_text("31100", encoding="utf-8")
|
||||
ready_path.touch()
|
||||
|
||||
fake_bin = _fake_cat(tmp_path, "exit 1")
|
||||
|
||||
result = _run_pid_line(
|
||||
pid_path,
|
||||
ready_path,
|
||||
fake_bin,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert pid_path.read_text(encoding="utf-8").strip() == "31100"
|
||||
assert not ready_path.exists()
|
||||
|
||||
|
||||
def test_windows_local_pid_line_rejects_malformed_mapping(tmp_path):
|
||||
pid_path = tmp_path / "serve.pid"
|
||||
ready_path = tmp_path / "serve.pid.ready"
|
||||
|
||||
pid_path.write_text("31100", encoding="utf-8")
|
||||
ready_path.touch()
|
||||
|
||||
fake_bin = _fake_cat(
|
||||
tmp_path,
|
||||
'printf "not-a-win32-pid\\n"',
|
||||
)
|
||||
|
||||
result = _run_pid_line(
|
||||
pid_path,
|
||||
ready_path,
|
||||
fake_bin,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert pid_path.read_text(encoding="utf-8").strip() == "31100"
|
||||
assert not ready_path.exists()
|
||||
|
||||
|
||||
def test_local_windows_launcher_publishes_fallback_before_releasing_inner_runner():
|
||||
source = COOKBOOK_ROUTES.read_text(encoding="utf-8")
|
||||
start = source.index(" def _launch_local_detached(")
|
||||
end = source.index(
|
||||
' @router.post("/api/model/download")',
|
||||
start,
|
||||
)
|
||||
launcher = source[start:end]
|
||||
|
||||
assert "_windows_local_pid_record_line(pid_path, pid_ready_path)" in launcher
|
||||
assert "pid_ready_path.unlink(missing_ok=True)" in launcher
|
||||
|
||||
fallback = launcher.index(
|
||||
'pid_path.write_text(str(proc.pid), encoding="utf-8")'
|
||||
)
|
||||
release = launcher.index("pid_ready_path.touch()")
|
||||
|
||||
assert fallback < release
|
||||
|
||||
# Never write Git Bash's bare MSYS $$ to the session pid file.
|
||||
assert '\\"$$\\" > {pp}' not in launcher
|
||||
@@ -0,0 +1,522 @@
|
||||
"""Regressions for process-safe email-account default mutations.
|
||||
|
||||
The file-backed SQLite fixture uses a fresh connection for every Session.
|
||||
That exercises the same database lock boundary used by separate web workers,
|
||||
rather than relying on an in-process Python lock.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, create_mock_engine, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account_db(tmp_path, monkeypatch):
|
||||
from core import database as core_db
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'accounts.db'}",
|
||||
connect_args={"check_same_thread": False, "timeout": 5},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
core_db.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(
|
||||
bind=engine,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
monkeypatch.setattr(core_db, "SessionLocal", factory)
|
||||
yield factory
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _endpoint(method, path):
|
||||
from routes import email_routes
|
||||
|
||||
with mock.patch.object(email_routes, "_start_poller"):
|
||||
router = email_routes.setup_email_routes()
|
||||
for route in router.routes:
|
||||
if route.path == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"email route not found: {method} {path}")
|
||||
|
||||
|
||||
def _named_endpoint(router, name):
|
||||
for route in router.routes:
|
||||
if getattr(getattr(route, "endpoint", None), "__name__", "") == name:
|
||||
return route.endpoint
|
||||
raise AssertionError(f"route not found: {name}")
|
||||
|
||||
|
||||
def _seed_account(factory, account_id, owner, *, is_default=False, enabled=True):
|
||||
from core.database import EmailAccount
|
||||
|
||||
db = factory()
|
||||
try:
|
||||
db.add(
|
||||
EmailAccount(
|
||||
id=account_id,
|
||||
owner=owner,
|
||||
name=account_id,
|
||||
is_default=is_default,
|
||||
enabled=enabled,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _rows(factory):
|
||||
from core.database import EmailAccount
|
||||
|
||||
db = factory()
|
||||
try:
|
||||
return [
|
||||
(row.id, row.owner, bool(row.is_default))
|
||||
for row in db.query(EmailAccount).order_by(EmailAccount.id).all()
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _install_lock_pause(monkeypatch, paused_thread_name):
|
||||
"""Pause one worker after acquisition and observe another waiting."""
|
||||
from routes import email_routes
|
||||
|
||||
real_lock = email_routes._lock_email_account_owner_mutation
|
||||
first_acquired = threading.Event()
|
||||
release_first = threading.Event()
|
||||
contender_attempted = threading.Event()
|
||||
contender_acquired = threading.Event()
|
||||
|
||||
def controlled_lock(db, owner):
|
||||
is_first = threading.current_thread().name == paused_thread_name
|
||||
if not is_first:
|
||||
contender_attempted.set()
|
||||
real_lock(db, owner)
|
||||
if is_first:
|
||||
first_acquired.set()
|
||||
assert release_first.wait(5), "timed out releasing first mutation"
|
||||
else:
|
||||
contender_acquired.set()
|
||||
|
||||
monkeypatch.setattr(
|
||||
email_routes,
|
||||
"_lock_email_account_owner_mutation",
|
||||
controlled_lock,
|
||||
)
|
||||
return first_acquired, release_first, contender_attempted, contender_acquired
|
||||
|
||||
|
||||
def test_concurrent_first_account_creates_choose_one_default(account_db, monkeypatch):
|
||||
create_account = _endpoint("POST", "/api/email/accounts")
|
||||
first_acquired, release_first, attempted, acquired = _install_lock_pause(
|
||||
monkeypatch, "first-account"
|
||||
)
|
||||
results = {}
|
||||
|
||||
def create(name):
|
||||
results[name] = asyncio.run(
|
||||
create_account({"name": name, "is_default": False}, owner="alice")
|
||||
)
|
||||
|
||||
first = threading.Thread(target=create, args=("First",), name="first-account")
|
||||
second = threading.Thread(target=create, args=("Second",), name="second-account")
|
||||
first.start()
|
||||
assert first_acquired.wait(5)
|
||||
second.start()
|
||||
assert attempted.wait(5)
|
||||
assert not acquired.wait(0.1), "second session bypassed the database mutation lock"
|
||||
|
||||
release_first.set()
|
||||
first.join(5)
|
||||
second.join(5)
|
||||
|
||||
assert not first.is_alive()
|
||||
assert not second.is_alive()
|
||||
assert results["First"]["ok"] is True
|
||||
assert results["Second"]["ok"] is True
|
||||
defaults = [row for row in _rows(account_db) if row[2]]
|
||||
assert [(row[1], row[2]) for row in defaults] == [("alice", True)]
|
||||
assert len(defaults) == 1
|
||||
|
||||
|
||||
def test_delete_promotion_and_set_default_are_one_serial_transition(
|
||||
account_db, monkeypatch
|
||||
):
|
||||
from sqlalchemy.orm import Session as OrmSession
|
||||
|
||||
_seed_account(account_db, "alice-a", "alice", is_default=True)
|
||||
_seed_account(account_db, "alice-b", "alice")
|
||||
_seed_account(account_db, "alice-c", "alice")
|
||||
_seed_account(account_db, "bob-a", "bob", is_default=True)
|
||||
|
||||
delete_account = _endpoint("DELETE", "/api/email/accounts/{account_id}")
|
||||
set_default = _endpoint("POST", "/api/email/accounts/{account_id}/set-default")
|
||||
first_acquired, release_first, attempted, acquired = _install_lock_pause(
|
||||
monkeypatch, "delete-default"
|
||||
)
|
||||
delete_commit_finished = threading.Event()
|
||||
release_delete_after_commit = threading.Event()
|
||||
real_commit = OrmSession.commit
|
||||
results = {}
|
||||
|
||||
def pause_after_delete_commit(session):
|
||||
real_commit(session)
|
||||
if (
|
||||
threading.current_thread().name == "delete-default"
|
||||
and not delete_commit_finished.is_set()
|
||||
):
|
||||
delete_commit_finished.set()
|
||||
assert release_delete_after_commit.wait(5), (
|
||||
"timed out releasing delete after its first commit"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OrmSession, "commit", pause_after_delete_commit)
|
||||
|
||||
def delete_old_default():
|
||||
results["delete"] = asyncio.run(
|
||||
delete_account("alice-a", owner="alice")
|
||||
)
|
||||
|
||||
def select_new_default():
|
||||
results["set"] = asyncio.run(
|
||||
set_default("alice-c", owner="alice")
|
||||
)
|
||||
|
||||
delete_thread = threading.Thread(target=delete_old_default, name="delete-default")
|
||||
set_thread = threading.Thread(target=select_new_default, name="set-default")
|
||||
delete_thread.start()
|
||||
assert first_acquired.wait(5)
|
||||
set_thread.start()
|
||||
assert attempted.wait(5)
|
||||
assert not acquired.wait(0.1), "set-default bypassed the delete transaction"
|
||||
|
||||
release_first.set()
|
||||
assert delete_commit_finished.wait(5)
|
||||
# The deletion transaction has committed. Let the contender complete
|
||||
# before the deleting handler can continue: if promotion were still a
|
||||
# second commit, it would now run after set-default and recreate two
|
||||
# defaults deterministically.
|
||||
assert acquired.wait(5)
|
||||
set_thread.join(5)
|
||||
release_delete_after_commit.set()
|
||||
delete_thread.join(5)
|
||||
|
||||
assert not delete_thread.is_alive()
|
||||
assert not set_thread.is_alive()
|
||||
assert results == {"delete": {"ok": True}, "set": {"ok": True}}
|
||||
assert _rows(account_db) == [
|
||||
("alice-b", "alice", False),
|
||||
("alice-c", "alice", True),
|
||||
("bob-a", "bob", True),
|
||||
]
|
||||
|
||||
|
||||
def test_upgrade_normalizes_legacy_defaults_and_installs_unique_index(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""A pre-index schema upgrades without requiring newer account columns."""
|
||||
from core import database as core_db
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'legacy-accounts.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
CREATE TABLE email_accounts (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
owner VARCHAR,
|
||||
name VARCHAR NOT NULL,
|
||||
is_default BOOLEAN NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)
|
||||
"""))
|
||||
conn.execute(text("""
|
||||
INSERT INTO email_accounts
|
||||
(id, owner, name, is_default, enabled, created_at, updated_at)
|
||||
VALUES
|
||||
('legacy-old', NULL, 'Old', 1, 1, '2024-01-01', '2024-01-01'),
|
||||
('legacy-new', '', 'New', 1, 1, '2025-01-01', '2025-01-01')
|
||||
"""))
|
||||
|
||||
monkeypatch.setattr(core_db, "engine", engine)
|
||||
core_db._migrate_email_account_default_invariant()
|
||||
core_db._migrate_email_account_default_invariant() # idempotent replay
|
||||
|
||||
with engine.connect() as conn:
|
||||
defaults = conn.execute(text("""
|
||||
SELECT id FROM email_accounts
|
||||
WHERE is_default IS TRUE
|
||||
ORDER BY id
|
||||
""")).scalars().all()
|
||||
index_names = {
|
||||
row[1] for row in conn.execute(text("PRAGMA index_list(email_accounts)"))
|
||||
}
|
||||
assert defaults == ["legacy-old"]
|
||||
assert core_db._EMAIL_ACCOUNT_DEFAULT_INDEX in index_names
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
INSERT INTO email_accounts
|
||||
(id, owner, name, is_default, enabled, created_at, updated_at)
|
||||
VALUES
|
||||
('legacy-third', NULL, 'Third', 1, 1, '2026-01-01', '2026-01-01')
|
||||
"""))
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_concurrent_legacy_seed_is_one_locked_transaction(
|
||||
tmp_path, monkeypatch, caplog
|
||||
):
|
||||
from core import database as core_db
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'seed-accounts.db'}",
|
||||
connect_args={"check_same_thread": False, "timeout": 5},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
core_db.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
settings_file = tmp_path / "settings.json"
|
||||
settings_file.write_text(
|
||||
json.dumps({"imap_host": "imap.example.test", "imap_user": "alice"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(core_db, "engine", engine)
|
||||
monkeypatch.setattr(core_db, "SessionLocal", factory)
|
||||
monkeypatch.setattr(core_db, "SETTINGS_FILE", str(settings_file))
|
||||
|
||||
read_barrier = threading.Barrier(2)
|
||||
real_read_text = Path.read_text
|
||||
|
||||
def synchronized_read(path, *args, **kwargs):
|
||||
value = real_read_text(path, *args, **kwargs)
|
||||
if path == settings_file:
|
||||
read_barrier.wait(5)
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", synchronized_read)
|
||||
threads = [
|
||||
threading.Thread(target=core_db._migrate_seed_email_account)
|
||||
for _ in range(2)
|
||||
]
|
||||
try:
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(5)
|
||||
assert all(not thread.is_alive() for thread in threads)
|
||||
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text("""
|
||||
SELECT owner, is_default FROM email_accounts
|
||||
ORDER BY id
|
||||
""")).all()
|
||||
assert rows == [(None, 1)]
|
||||
assert "seed email account migration:" not in caplog.text
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_multi_owner_row_locks_are_acquired_in_canonical_order():
|
||||
from core.database import lock_email_account_owner_mutations
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self):
|
||||
self.locked = []
|
||||
|
||||
def get_bind(self):
|
||||
return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
|
||||
|
||||
def get(self, _model, owner_key, **kwargs):
|
||||
assert kwargs == {"with_for_update": True}
|
||||
self.locked.append(owner_key)
|
||||
return object()
|
||||
|
||||
db = FakeSession()
|
||||
lock_email_account_owner_mutations(db, "zeta", "", "alpha", "zeta")
|
||||
assert db.locked == ["", "alpha", "zeta"]
|
||||
|
||||
|
||||
def test_postgresql_fresh_schema_emits_default_unique_index():
|
||||
from core import database as core_db
|
||||
|
||||
statements = []
|
||||
engine_holder = {}
|
||||
|
||||
def capture(statement, *_args, **_kwargs):
|
||||
statements.append(
|
||||
str(statement.compile(dialect=engine_holder["engine"].dialect))
|
||||
)
|
||||
|
||||
mock_engine = create_mock_engine("postgresql://", capture)
|
||||
engine_holder["engine"] = mock_engine
|
||||
core_db.EmailAccount.__table__.create(mock_engine)
|
||||
|
||||
assert any(
|
||||
core_db._EMAIL_ACCOUNT_DEFAULT_INDEX in statement
|
||||
and "COALESCE(owner, '')" in statement
|
||||
and "WHERE is_default IS TRUE" in statement
|
||||
for statement in statements
|
||||
)
|
||||
|
||||
|
||||
def test_rename_serializes_old_and_new_owner_and_stale_set_default_fails_closed(
|
||||
account_db, monkeypatch, tmp_path
|
||||
):
|
||||
from core import database as core_db
|
||||
from routes import auth_routes
|
||||
|
||||
_seed_account(account_db, "alice-a", "alice", is_default=True)
|
||||
_seed_account(account_db, "alice-b", "alice")
|
||||
_seed_account(account_db, "bob-a", "bob", is_default=True)
|
||||
|
||||
prefs_module = types.ModuleType("routes.prefs_routes")
|
||||
prefs_module._load = lambda: {}
|
||||
prefs_module._save = lambda _data: None
|
||||
monkeypatch.setitem(sys.modules, "routes.prefs_routes", prefs_module)
|
||||
monkeypatch.setattr(
|
||||
auth_routes, "DEEP_RESEARCH_DIR", str(tmp_path / "deep_research")
|
||||
)
|
||||
monkeypatch.setattr(auth_routes, "MEMORY_FILE", str(tmp_path / "memory.json"))
|
||||
monkeypatch.setattr(auth_routes, "SKILLS_DIR", str(tmp_path / "skills"))
|
||||
|
||||
auth_manager = mock.MagicMock()
|
||||
auth_manager.get_username_for_token.return_value = "admin"
|
||||
auth_manager.is_admin.return_value = True
|
||||
auth_manager.users = {"admin": {}, "alice": {}}
|
||||
auth_manager.rename_user.return_value = True
|
||||
rename_user = _named_endpoint(
|
||||
auth_routes.setup_auth_routes(auth_manager), "rename_user"
|
||||
)
|
||||
set_default = _endpoint("POST", "/api/email/accounts/{account_id}/set-default")
|
||||
|
||||
rename_acquired = threading.Event()
|
||||
release_rename = threading.Event()
|
||||
set_attempted = threading.Event()
|
||||
set_acquired = threading.Event()
|
||||
real_lock = core_db.lock_email_account_owner_mutations
|
||||
|
||||
def controlled_lock(db, *owners):
|
||||
thread_name = threading.current_thread().name
|
||||
if thread_name == "rename-owner":
|
||||
real_lock(db, *owners)
|
||||
rename_acquired.set()
|
||||
assert release_rename.wait(5)
|
||||
return
|
||||
if thread_name == "stale-set-default":
|
||||
set_attempted.set()
|
||||
real_lock(db, *owners)
|
||||
set_acquired.set()
|
||||
return
|
||||
real_lock(db, *owners)
|
||||
|
||||
monkeypatch.setattr(core_db, "lock_email_account_owner_mutations", controlled_lock)
|
||||
request = SimpleNamespace(
|
||||
cookies={"odysseus_session": "admin-token"},
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
invalidate_token_cache=lambda: None,
|
||||
session_manager=None,
|
||||
research_handler=None,
|
||||
upload_handler=None,
|
||||
personal_docs_manager=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
results = {}
|
||||
|
||||
def rename_owner():
|
||||
results["rename"] = asyncio.run(
|
||||
rename_user("alice", SimpleNamespace(username="bob"), request)
|
||||
)
|
||||
|
||||
def select_stale_default():
|
||||
try:
|
||||
results["set"] = asyncio.run(
|
||||
set_default("alice-b", owner="alice")
|
||||
)
|
||||
except Exception as exc: # asserted below with its HTTP status
|
||||
results["set_error"] = exc
|
||||
|
||||
rename_thread = threading.Thread(target=rename_owner, name="rename-owner")
|
||||
set_thread = threading.Thread(
|
||||
target=select_stale_default, name="stale-set-default"
|
||||
)
|
||||
rename_thread.start()
|
||||
assert rename_acquired.wait(5)
|
||||
set_thread.start()
|
||||
assert set_attempted.wait(5)
|
||||
assert not set_acquired.wait(0.1), "set-default bypassed the rename lock"
|
||||
|
||||
release_rename.set()
|
||||
rename_thread.join(5)
|
||||
set_thread.join(5)
|
||||
|
||||
assert not rename_thread.is_alive()
|
||||
assert not set_thread.is_alive()
|
||||
assert results["rename"]["ok"] is True
|
||||
assert isinstance(results["set_error"], HTTPException)
|
||||
assert results["set_error"].status_code == 404
|
||||
assert _rows(account_db) == [
|
||||
("alice-a", "bob", False),
|
||||
("alice-b", "bob", False),
|
||||
("bob-a", "bob", True),
|
||||
]
|
||||
|
||||
|
||||
def test_demo_teardown_promotes_replacement_in_same_transaction(
|
||||
account_db, monkeypatch
|
||||
):
|
||||
from core.database import EmailAccount
|
||||
from scripts.demo_email import demo_account
|
||||
|
||||
db = account_db()
|
||||
try:
|
||||
db.add_all([
|
||||
EmailAccount(
|
||||
id="real",
|
||||
owner="",
|
||||
name="Real",
|
||||
is_default=False,
|
||||
enabled=True,
|
||||
),
|
||||
EmailAccount(
|
||||
id="demo",
|
||||
owner="",
|
||||
name=demo_account.NAME,
|
||||
imap_user=demo_account.IMAP_USER,
|
||||
is_default=True,
|
||||
enabled=True,
|
||||
),
|
||||
])
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
monkeypatch.setattr(demo_account, "SessionLocal", account_db)
|
||||
monkeypatch.setattr(demo_account, "engine", account_db.kw["bind"])
|
||||
|
||||
assert demo_account.teardown() == 0
|
||||
assert _rows(account_db) == [("real", "", True)]
|
||||
+122
-2
@@ -29,6 +29,7 @@ import base64
|
||||
import json
|
||||
import time
|
||||
import unittest.mock as mock
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -272,8 +273,14 @@ def _callback_endpoint():
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
"""Minimal stand-in for starlette Request — the callback only reads headers."""
|
||||
headers = {"host": "localhost:7000"}
|
||||
"""Minimal stand-in for starlette Request — the callback reads the Host header
|
||||
and the request scheme. Behind a TLS terminator uvicorn's proxy-headers
|
||||
middleware rewrites the scheme from `X-Forwarded-Proto`, so the route sees
|
||||
`https` there and `http` on a plain origin."""
|
||||
|
||||
def __init__(self, scheme="http", host="localhost:7000"):
|
||||
self.headers = {"host": host}
|
||||
self.url = SimpleNamespace(scheme=scheme)
|
||||
|
||||
|
||||
def _location(resp):
|
||||
@@ -415,6 +422,119 @@ async def test_callback_valid_owner_writes_encrypted_tokens_to_intended_account(
|
||||
assert other.oauth_access_token is None, "tokens must only touch the intended account"
|
||||
|
||||
|
||||
# ── Redirect URI scheme ───────────────────────────────────────────
|
||||
#
|
||||
# Google rejects the token exchange unless the callback's `redirect_uri` is
|
||||
# byte-identical to the one the authorize step sent, so both routes have to
|
||||
# agree — including on the scheme. Deriving it from the request keeps HTTPS
|
||||
# deployments working without pinning GOOGLE_OAUTH_REDIRECT_URI by hand;
|
||||
# hardcoding `http://` produced an unusable redirect behind any TLS front.
|
||||
|
||||
def _authorize_endpoint():
|
||||
"""Return the live google_oauth_authorize endpoint from the email router."""
|
||||
from routes.email_routes import setup_email_routes
|
||||
router = setup_email_routes()
|
||||
for route in router.routes:
|
||||
if route.path == "/api/email/oauth/google/authorize" and "GET" in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError("google_oauth_authorize route not found")
|
||||
|
||||
|
||||
def _posted_redirect_uri(mock_post):
|
||||
"""Pull `redirect_uri` out of the mocked Google token-exchange POST."""
|
||||
return mock_post.call_args.kwargs["data"]["redirect_uri"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scheme", ("http", "https"))
|
||||
async def test_callback_redirect_uri_follows_the_request_scheme(scheme, monkeypatch):
|
||||
"""The token exchange must echo the scheme the request actually arrived on —
|
||||
`https` behind a TLS terminator, `http` on a plain origin."""
|
||||
from routes.email_helpers import make_oauth_state
|
||||
|
||||
monkeypatch.delenv("GOOGLE_OAUTH_REDIRECT_URI", raising=False)
|
||||
|
||||
db, Factory = _make_db()
|
||||
_make_account(db, account_id="acct-s", owner="alice", imap_user="alice@example.com")
|
||||
db.close()
|
||||
|
||||
token_resp = mock.MagicMock()
|
||||
token_resp.raise_for_status = mock.MagicMock()
|
||||
token_resp.json.return_value = {"access_token": "ya29.t", "refresh_token": "1//r", "expires_in": 3600}
|
||||
userinfo_resp = mock.MagicMock()
|
||||
userinfo_resp.is_success = True
|
||||
userinfo_resp.json.return_value = {"email": "alice@example.com", "name": "Alice"}
|
||||
|
||||
state = make_oauth_state("acct-s", "alice")
|
||||
|
||||
with mock.patch("httpx.post", return_value=token_resp) as mock_post, \
|
||||
mock.patch("httpx.get", return_value=userinfo_resp), \
|
||||
mock.patch("core.database.SessionLocal", Factory):
|
||||
callback = _callback_endpoint()
|
||||
await callback(
|
||||
code="4/code", state=state, error=None,
|
||||
request=_FakeRequest(scheme=scheme, host="odysseus.example.ts.net:7443"),
|
||||
)
|
||||
|
||||
assert _posted_redirect_uri(mock_post) == (
|
||||
f"{scheme}://odysseus.example.ts.net:7443/api/email/oauth/google/callback"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_redirect_uri_env_override_still_wins(monkeypatch):
|
||||
"""An explicit GOOGLE_OAUTH_REDIRECT_URI is used verbatim — deriving the
|
||||
scheme must not override a value the operator pinned by hand."""
|
||||
from routes.email_helpers import make_oauth_state
|
||||
|
||||
pinned = "https://mail.example.com/api/email/oauth/google/callback"
|
||||
monkeypatch.setenv("GOOGLE_OAUTH_REDIRECT_URI", pinned)
|
||||
|
||||
db, Factory = _make_db()
|
||||
_make_account(db, account_id="acct-p", owner="alice", imap_user="alice@example.com")
|
||||
db.close()
|
||||
|
||||
token_resp = mock.MagicMock()
|
||||
token_resp.raise_for_status = mock.MagicMock()
|
||||
token_resp.json.return_value = {"access_token": "ya29.t", "refresh_token": "1//r", "expires_in": 3600}
|
||||
userinfo_resp = mock.MagicMock()
|
||||
userinfo_resp.is_success = True
|
||||
userinfo_resp.json.return_value = {"email": "alice@example.com", "name": "Alice"}
|
||||
|
||||
state = make_oauth_state("acct-p", "alice")
|
||||
|
||||
with mock.patch("httpx.post", return_value=token_resp) as mock_post, \
|
||||
mock.patch("httpx.get", return_value=userinfo_resp), \
|
||||
mock.patch("core.database.SessionLocal", Factory):
|
||||
callback = _callback_endpoint()
|
||||
await callback(code="4/code", state=state, error=None, request=_FakeRequest(scheme="http"))
|
||||
|
||||
assert _posted_redirect_uri(mock_post) == pinned
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scheme", ("http", "https"))
|
||||
async def test_authorize_redirect_uri_follows_the_request_scheme(scheme, monkeypatch):
|
||||
"""The authorize step builds the same redirect_uri the callback will send.
|
||||
`owner=""` is the unconfigured / single-user case, so no DB is touched."""
|
||||
import urllib.parse
|
||||
|
||||
monkeypatch.delenv("GOOGLE_OAUTH_REDIRECT_URI", raising=False)
|
||||
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "client-id.apps.googleusercontent.com")
|
||||
|
||||
authorize = _authorize_endpoint()
|
||||
resp = await authorize(
|
||||
account_id="acct-a",
|
||||
request=_FakeRequest(scheme=scheme, host="odysseus.example.ts.net:7443"),
|
||||
owner="",
|
||||
)
|
||||
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(resp.headers["location"]).query)
|
||||
assert query["redirect_uri"] == [
|
||||
f"{scheme}://odysseus.example.ts.net:7443/api/email/oauth/google/callback"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_rejects_token_for_a_different_mailbox_identity():
|
||||
"""Reconnecting with another Google identity must not replace the token
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import routes.gallery_routes as gallery_routes
|
||||
|
||||
|
||||
class _TorchSentinel:
|
||||
float32 = object()
|
||||
float64 = object()
|
||||
|
||||
|
||||
class _FakeTensor:
|
||||
def __init__(self, dtype):
|
||||
self.dtype = dtype
|
||||
self.to_args = None
|
||||
|
||||
def to(self, *args, **kwargs):
|
||||
self.to_args = (args, kwargs)
|
||||
return self
|
||||
|
||||
|
||||
def test_model_inputs_to_device_casts_mps_float64_to_float32():
|
||||
float_tensor = _FakeTensor(_TorchSentinel.float64)
|
||||
int_tensor = _FakeTensor("int64")
|
||||
plain_value = object()
|
||||
|
||||
result = gallery_routes._model_inputs_to_device(
|
||||
{"points": float_tensor, "labels": int_tensor, "plain": plain_value},
|
||||
"mps",
|
||||
_TorchSentinel,
|
||||
)
|
||||
|
||||
assert result["points"] is float_tensor
|
||||
assert float_tensor.to_args == ((), {"device": "mps", "dtype": _TorchSentinel.float32})
|
||||
assert int_tensor.to_args == (("mps",), {})
|
||||
assert result["plain"] is plain_value
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tools whose names collide with harmony built-ins must be aliased for gpt-oss.
|
||||
|
||||
gpt-oss (harmony format) ships BUILT-IN tools named `python` and `browser`,
|
||||
invoked with the raw body as the argument (`to=python` + bare source), while
|
||||
custom functions use `to=functions.NAME` + JSON. Exposing our own tool under a
|
||||
built-in's name makes the model answer with the built-in convention: it emits
|
||||
raw code, the server tries to parse it as JSON, and the request dies with
|
||||
"error parsing tool call: raw='import sys, ...'". Streaming is worse — Ollama
|
||||
truncates the stream instead of reporting it, so the turn looks like an empty
|
||||
response and the agent loop reads it as a stall.
|
||||
|
||||
Measured on gpt-oss:20b via Ollama /v1 with a fixed agentic prompt:
|
||||
python+bash as-is 2/6, python renamed 5/6, both renamed 6/6.
|
||||
|
||||
The aliasing is transport-only and gpt-oss-only: every other model's tool
|
||||
schemas must pass through untouched, and real tool names must come back out.
|
||||
"""
|
||||
from src.llm_core import (
|
||||
_alias_harmony_tools,
|
||||
_unalias_harmony_tool_name,
|
||||
_is_harmony_model,
|
||||
)
|
||||
|
||||
|
||||
def _tools(*names):
|
||||
return [
|
||||
{"type": "function", "function": {"name": n, "parameters": {}}}
|
||||
for n in names
|
||||
]
|
||||
|
||||
|
||||
def _names(tools):
|
||||
return [t["function"]["name"] for t in tools]
|
||||
|
||||
|
||||
def test_gpt_oss_colliding_names_are_aliased():
|
||||
out = _alias_harmony_tools(_tools("python", "bash", "web_search"), "gpt-oss:20b")
|
||||
assert _names(out) == ["run_python_code", "run_shell_command", "web_search"]
|
||||
|
||||
|
||||
def test_non_harmony_models_are_untouched():
|
||||
tools = _tools("python", "bash", "web_search")
|
||||
for model in ("qwen3-coder:30b", "gemma4:12b", "claude-opus-5", "gpt-4o", "llama-3.3"):
|
||||
out = _alias_harmony_tools(tools, model)
|
||||
assert _names(out) == ["python", "bash", "web_search"], model
|
||||
assert out is tools, f"{model} should get the same list object, not a copy"
|
||||
|
||||
|
||||
def test_aliasing_does_not_mutate_the_caller_list():
|
||||
tools = _tools("python")
|
||||
_alias_harmony_tools(tools, "gpt-oss:20b")
|
||||
assert _names(tools) == ["python"], "caller's schema list must not be mutated"
|
||||
|
||||
|
||||
def test_response_names_map_back_for_gpt_oss():
|
||||
assert _unalias_harmony_tool_name("run_python_code", "gpt-oss:20b") == "python"
|
||||
assert _unalias_harmony_tool_name("run_shell_command", "gpt-oss:20b") == "bash"
|
||||
# Unrelated names pass through untouched.
|
||||
assert _unalias_harmony_tool_name("web_search", "gpt-oss:20b") == "web_search"
|
||||
|
||||
|
||||
def test_response_names_untouched_for_other_models():
|
||||
# A non-harmony model that genuinely has a tool called run_python_code
|
||||
# must not have it rewritten to `python`.
|
||||
assert _unalias_harmony_tool_name("run_python_code", "qwen3-coder:30b") == "run_python_code"
|
||||
|
||||
|
||||
def test_harmony_detection():
|
||||
assert _is_harmony_model("gpt-oss:20b") is True
|
||||
assert _is_harmony_model("GPT-OSS:120B") is True
|
||||
assert _is_harmony_model("qwen3-coder:30b") is False
|
||||
assert _is_harmony_model("") is False
|
||||
assert _is_harmony_model(None) is False
|
||||
|
||||
|
||||
def test_empty_and_none_tools_are_safe():
|
||||
assert _alias_harmony_tools(None, "gpt-oss:20b") is None
|
||||
assert _alias_harmony_tools([], "gpt-oss:20b") == []
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Regression for idle UI polls that must not count as foreground activity."""
|
||||
|
||||
from src.interactive_gate import should_track_interactive_request
|
||||
|
||||
|
||||
def test_email_unread_state_is_passive_like_urgency_state():
|
||||
assert should_track_interactive_request("/api/email/urgency-state") is False
|
||||
assert should_track_interactive_request("/api/email/unread-state") is False
|
||||
|
||||
|
||||
def test_real_interactive_paths_still_tracked():
|
||||
assert should_track_interactive_request("/api/chat_stream") is True
|
||||
assert should_track_interactive_request("/api/email/messages") is True
|
||||
assert should_track_interactive_request("/api/tasks", method="POST") is True
|
||||
|
||||
|
||||
def test_options_never_tracked():
|
||||
assert should_track_interactive_request("/api/email/unread-state", method="OPTIONS") is False
|
||||
assert should_track_interactive_request("/api/chat_stream", method="OPTIONS") is False
|
||||
@@ -0,0 +1,31 @@
|
||||
"""The retired default fallback editor must not imply active routing."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_legacy_default_fallback_editor_is_absent():
|
||||
soup = BeautifulSoup(
|
||||
(_REPO / "static" / "index.html").read_text(encoding="utf-8"),
|
||||
"html.parser",
|
||||
)
|
||||
editor = soup.find(id="set-defaultFallbacks")
|
||||
|
||||
assert editor is None
|
||||
assert soup.find(id="set-defaultAddFallback") is None
|
||||
|
||||
|
||||
def test_default_model_save_does_not_rewrite_legacy_fallbacks():
|
||||
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
||||
start = source.index("async function initDefaultChat()")
|
||||
end = source.index("/* ── Utility Model ── */", start)
|
||||
default_chat_source = source[start:end]
|
||||
|
||||
assert "settings.default_model_fallbacks" not in default_chat_source
|
||||
assert "default_model_fallbacks:" not in default_chat_source
|
||||
assert "set-defaultFallbacks" not in default_chat_source
|
||||
assert "set-defaultAddFallback" not in default_chat_source
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Source contract for live multi-round fallback attribution."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
CHAT_JS = Path("static/js/chat.js").read_text(encoding="utf-8")
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _resume_function_source():
|
||||
body = CHAT_JS.split("export async function resumeStream", 1)[1].split(
|
||||
"export function checkBackgroundStream", 1
|
||||
)[0]
|
||||
return "async function resumeStream" + body.rstrip()
|
||||
|
||||
|
||||
def _run_node(source):
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=source,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
def test_live_fallback_targets_the_active_round_and_replaces_actual_model():
|
||||
fallback_block = CHAT_JS.split("json.type === 'fallback'", 1)[1].split(
|
||||
"json.type === 'doc_stream_open'", 1
|
||||
)[0]
|
||||
|
||||
assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in fallback_block
|
||||
assert "_fallbackHolder.querySelector('.role')" in fallback_block
|
||||
assert "_hasResolvedActual" not in fallback_block
|
||||
|
||||
|
||||
def test_provider_alias_uses_the_same_round_aware_holder_selection():
|
||||
actual_block = CHAT_JS.split("json.type === 'model_actual'", 1)[1].split(
|
||||
"json.type === 'attachments'", 1
|
||||
)[0]
|
||||
|
||||
assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in actual_block
|
||||
assert "_modelHolder.querySelector('.role')" in actual_block
|
||||
|
||||
|
||||
def test_new_round_and_final_metrics_target_the_active_round():
|
||||
agent_step_block = CHAT_JS.split("} else if (json.type === 'agent_step')", 1)[1].split(
|
||||
"json.type === 'budget_exceeded'", 1
|
||||
)[0]
|
||||
metrics_block = CHAT_JS.split("json.type === 'metrics'", 1)[1].split(
|
||||
"json.type === 'message_saved'", 1
|
||||
)[0]
|
||||
final_block = CHAT_JS.split("const _isBgFinal", 1)[1].split(
|
||||
"holder.dataset.raw", 1
|
||||
)[0]
|
||||
|
||||
assert "inheritModelRouteState(holder, roundHolder, newWrap" in agent_step_block
|
||||
assert "applyModelMetricsState(metrics, holder, roundHolder, modelName)" in metrics_block
|
||||
assert "_finalModelHolder.querySelector('.role')" in final_block
|
||||
assert "holder.querySelector('.role')" not in final_block
|
||||
|
||||
|
||||
def test_terminal_sse_error_bypasses_eof_auto_recovery():
|
||||
parser_block = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
|
||||
"if (json.delta", 1
|
||||
)[0]
|
||||
completion_gate = CHAT_JS.split("if (_streamTerminalError)", 1)[1].split(
|
||||
"if (!_streamSawDone)", 1
|
||||
)[0]
|
||||
recovery_block = CHAT_JS.split("isRecoverableStreamError(err)", 1)[1].split(
|
||||
"const errorHolder", 1
|
||||
)[0]
|
||||
|
||||
assert "createTerminalStreamError(json)" in parser_block
|
||||
assert "throw _streamTerminalError" in completion_gate
|
||||
assert "if (err.terminalStreamError)" in recovery_block
|
||||
assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in recovery_block
|
||||
|
||||
|
||||
def test_connection_recovery_resumes_detached_run_without_resubmitting_selected_model():
|
||||
recovery = CHAT_JS.split("function _tryAutoRecover", 1)[1].split(
|
||||
"function _removeStallBanner", 1
|
||||
)[0]
|
||||
|
||||
assert "await resumeStream(sessionId, holder || null)" in recovery
|
||||
assert "/api/chat_stream" not in recovery
|
||||
assert ".click()" not in recovery
|
||||
assert "_pendingContinue" not in recovery
|
||||
assert "if (_streamSessionId === streamSessionId) _streamSessionId = null" in CHAT_JS
|
||||
|
||||
|
||||
def test_detached_resume_reloads_canonical_terminal_failures():
|
||||
resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
|
||||
"export function checkBackgroundStream", 1
|
||||
)[0]
|
||||
|
||||
assert "l.trim() === 'event: error'" in resume
|
||||
assert "json.type === 'agent_terminal'" in resume
|
||||
assert "rich = true" in resume
|
||||
assert "Network drop or parse failure: fall through to the canonical reload" in resume
|
||||
assert "if (onThisSession && !rich && roundText.trim())" in resume
|
||||
assert "res.headers.get('X-Odysseus-Run-Id')" in resume
|
||||
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in resume
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_detached_resume_surfaces_fallback_then_provider_alias_before_reload():
|
||||
source = "\n".join([
|
||||
"import { applyModelRouteEventState } from './static/js/chatModelProvenance.js';",
|
||||
"class Element {",
|
||||
" constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
|
||||
" appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
|
||||
" remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
|
||||
" set innerHTML(value) {",
|
||||
" this._html = value;",
|
||||
" if (value.includes('stream-content')) {",
|
||||
" this._role = new Element('div'); this._role.parentNode = this;",
|
||||
" this._body = new Element('div'); this._body.parentNode = this;",
|
||||
" this._content = new Element('div'); this._body.appendChild(this._content);",
|
||||
" }",
|
||||
" }",
|
||||
" get innerHTML() { return this._html; }",
|
||||
" querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
|
||||
"}",
|
||||
"const box = new Element('main');",
|
||||
"const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
|
||||
"const window = {};",
|
||||
"let selectCalls = 0; const labels = []; const toasts = [];",
|
||||
"const sessionModule = { getSessions() { return [{id: 's1', model: 'selected-model'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
|
||||
"const uiModule = { esc(value) { return String(value); }, scrollHistory() {}, showToast(value) { toasts.push(value); } };",
|
||||
"const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
|
||||
"const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
|
||||
"const documentModule = null; const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
|
||||
"const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
|
||||
"function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
|
||||
"function _setRoleModelLabel(role, requested, actual) { labels.push({requested, actual}); role.textContent = requested + ' -> ' + actual; }",
|
||||
"function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
|
||||
"const events = [",
|
||||
" 'data: {\"type\":\"fallback\",\"selected_model\":\"selected-model\",\"answered_by\":\"fallback-model\",\"reason\":\"429\"}\\n\\n',",
|
||||
" 'data: {\"type\":\"model_actual\",\"model\":\"provider/fallback-alias\"}\\n\\n',",
|
||||
" 'data: {\"delta\":\"hello\"}\\n\\n',",
|
||||
" 'data: [DONE]\\n\\n',",
|
||||
"].join('');",
|
||||
"const encoded = new TextEncoder().encode(events); let reads = 0;",
|
||||
"const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
|
||||
"async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
|
||||
_resume_function_source(),
|
||||
"await resumeStream('s1');",
|
||||
"console.log(JSON.stringify({labels, toasts, selectCalls, holderCount: box.children.length}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"labels": [
|
||||
{"requested": "selected-model", "actual": "fallback-model"},
|
||||
{"requested": "selected-model", "actual": "provider/fallback-alias"},
|
||||
],
|
||||
"toasts": ["Fallback: selected-model failed — answered by fallback-model"],
|
||||
"selectCalls": 1,
|
||||
"holderCount": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_detached_resume_renders_preoutput_error_without_empty_reload():
|
||||
source = "\n".join([
|
||||
"import { createTerminalStreamError } from './static/js/chatStreamErrors.js';",
|
||||
"class Element {",
|
||||
" constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
|
||||
" appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
|
||||
" remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
|
||||
" set innerHTML(value) {",
|
||||
" this._html = value;",
|
||||
" if (value.includes('stream-content')) {",
|
||||
" this._role = new Element('div'); this._role.parentNode = this;",
|
||||
" this._body = new Element('div'); this._body.parentNode = this;",
|
||||
" this._content = new Element('div'); this._body.appendChild(this._content);",
|
||||
" }",
|
||||
" }",
|
||||
" get innerHTML() { return this._html; }",
|
||||
" querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
|
||||
"}",
|
||||
"const box = new Element('main');",
|
||||
"const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
|
||||
"const window = {};",
|
||||
"let selectCalls = 0;",
|
||||
"const sessionModule = { getSessions() { return [{id: 's1', model: 'selected'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
|
||||
"const uiModule = { esc(value) { return String(value); }, scrollHistory() {} };",
|
||||
"const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
|
||||
"const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
|
||||
"const documentModule = null;",
|
||||
"const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
|
||||
"const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
|
||||
"function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
|
||||
"function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
|
||||
"const encoded = new TextEncoder().encode('event: error\\ndata: {\"status\":401,\"error\":\"invalid key <img src=x>\"}\\n\\n');",
|
||||
"let reads = 0; const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
|
||||
"async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
|
||||
_resume_function_source(),
|
||||
"const result = await resumeStream('s1');",
|
||||
"const holder = box.children[0]; const errorNode = holder && holder._content.children.find(node => node.textContent.startsWith('[Error:'));",
|
||||
"console.log(JSON.stringify({result, selectCalls, holderCount: box.children.length, errorText: errorNode && errorNode.textContent}));",
|
||||
])
|
||||
|
||||
assert _run_node(source) == {
|
||||
"result": True,
|
||||
"selectCalls": 0,
|
||||
"holderCount": 1,
|
||||
"errorText": "[Error: invalid key <img src=x>]",
|
||||
}
|
||||
|
||||
|
||||
def test_terminal_then_session_switch_preserves_completed_background_state():
|
||||
terminal = CHAT_JS.split(
|
||||
"json.type === 'agent_terminal' || json.type === 'chat_terminal'", 1
|
||||
)[1].split("json.type === 'metrics'", 1)[0]
|
||||
detach = CHAT_JS.split("export function detachCurrentStream", 1)[1].split(
|
||||
"export async function resumeStream", 1
|
||||
)[0]
|
||||
background_catch = CHAT_JS.split("if (_isBgCatch)", 1)[1].split(
|
||||
"} else {", 1
|
||||
)[0]
|
||||
|
||||
assert "_terminalSavedStreams.add(streamSessionId)" in terminal
|
||||
assert "terminalSaved ? 'completed' : 'running'" in detach
|
||||
assert "!terminalSaved && sessionModule && sessionModule.markStreaming" in detach
|
||||
assert "_terminalSavedStreams.has(streamSessionId)" in background_catch
|
||||
|
||||
|
||||
def test_detached_run_identity_is_attached_to_live_metrics():
|
||||
routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
assert "headers={\"X-Odysseus-Run-Id\": _detached_run.run_id}" in routes
|
||||
assert "agent_runs.subscribe(session, _detached_run)" in routes
|
||||
assert "agent_runs.subscribe(session_id, _active_run)" in routes
|
||||
assert "const streamRunId = res.headers.get('X-Odysseus-Run-Id')" in CHAT_JS
|
||||
assert "metrics._costRecordId = _metricsCostRecordId(streamRunId, json)" in CHAT_JS
|
||||
assert "'X-Odysseus-Run-Id': runId" in CHAT_JS
|
||||
assert "agent_runs.stop(session_id, _expected_run_id)" in routes
|
||||
assert "_stopExactRun(streamSessionId)" in CHAT_JS
|
||||
timeout_block = CHAT_JS.split("timeoutId = setTimeout", 1)[1].split(
|
||||
"clearResponseTimeout", 1
|
||||
)[0]
|
||||
assert "/api/chat/stop/" not in timeout_block
|
||||
|
||||
|
||||
def test_replay_cost_identity_distinguishes_primary_and_teacher_segments():
|
||||
identity = CHAT_JS.split("function _metricsCostRecordId", 1)[1].split("\n }", 1)[0]
|
||||
resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
|
||||
"export function checkBackgroundStream", 1
|
||||
)[0]
|
||||
|
||||
assert "event.teacher ? 'teacher' : 'primary'" in identity
|
||||
assert "_metricsCostRecordId(resumeRunId, json)" in resume
|
||||
metrics_block = resume.split("json.type === 'metrics'", 1)[1].split(
|
||||
"json.type === 'agent_terminal'", 1
|
||||
)[0]
|
||||
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in metrics_block
|
||||
|
||||
routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
route_metrics = routes.split('elif data.get("type") == "metrics"', 1)[1].split(
|
||||
"except json.JSONDecodeError", 1
|
||||
)[0]
|
||||
assert 'if data.get("teacher") is True' in route_metrics
|
||||
assert '_metrics_event["teacher"] = True' in route_metrics
|
||||
|
||||
|
||||
def test_foreground_terminal_error_reloads_saved_partial_without_typewriter_race():
|
||||
parser = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
|
||||
"if (json.delta", 1
|
||||
)[0]
|
||||
terminal_catch = CHAT_JS.split("if (err.terminalStreamError)", 1)[1].split(
|
||||
"const errorHolder", 1
|
||||
)[0]
|
||||
|
||||
assert "typewriterInto" not in parser
|
||||
assert "json.type === 'agent_terminal'" in CHAT_JS
|
||||
assert "_canonicalTerminalSaved = true" in CHAT_JS
|
||||
assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in terminal_catch
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Integration regression test for #5435.
|
||||
|
||||
llm_call_async must normalise Mistral structured content to a plain string,
|
||||
matching llm_call (sync) and stream_llm. Before the fix, the async
|
||||
non-streaming parser returned the raw list when Mistral reasoning was enabled,
|
||||
violating its -> str contract, leaking a non-string into callers such as
|
||||
auto-title generation and memory extraction, and poisoning _response_cache
|
||||
with a non-string value.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import src.llm_core as llm_core
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
is_success = True
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _payload(content):
|
||||
return {"choices": [{"message": {"role": "assistant", "content": content}}]}
|
||||
|
||||
|
||||
def _call(monkeypatch, content):
|
||||
async def fake_post(client, url, headers, **kwargs):
|
||||
return _FakeResponse(_payload(content))
|
||||
|
||||
monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
|
||||
llm_core._response_cache.clear()
|
||||
return asyncio.run(llm_core.llm_call_async(
|
||||
"http://mistral.test/v1/chat/completions",
|
||||
"mistral-medium",
|
||||
[{"role": "user", "content": "q"}],
|
||||
))
|
||||
|
||||
|
||||
def test_llm_call_async_normalizes_mistral_structured_content(monkeypatch):
|
||||
out = _call(monkeypatch, [
|
||||
{"type": "thinking",
|
||||
"thinking": [{"type": "text", "text": "Let me work through this..."}],
|
||||
"closed": True},
|
||||
{"type": "text", "text": "The answer is 42."},
|
||||
])
|
||||
assert isinstance(out, str), f"expected str, got {type(out).__name__}"
|
||||
assert "The answer is 42." in out
|
||||
assert "Let me work through this..." in out
|
||||
# The cache must hold the normalised string, not the raw list,
|
||||
# otherwise repeat calls serve the poisoned value.
|
||||
assert all(isinstance(v, str) for v in llm_core._response_cache.values())
|
||||
|
||||
|
||||
def test_llm_call_async_thinking_only_still_returns_str(monkeypatch):
|
||||
out = _call(monkeypatch, [
|
||||
{"type": "thinking",
|
||||
"thinking": [{"type": "text", "text": "still thinking"}],
|
||||
"closed": True},
|
||||
])
|
||||
assert isinstance(out, str)
|
||||
assert "still thinking" in out
|
||||
|
||||
|
||||
def test_llm_call_async_plain_string_passthrough(monkeypatch):
|
||||
out = _call(monkeypatch, "plain answer")
|
||||
assert out == "plain answer"
|
||||
+1267
-14
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
"""Regression coverage for structured-thinking model detection."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
import pytest
|
||||
|
||||
from src.llm_core import _supports_thinking
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"deepseek-v4",
|
||||
"deepseek-v4-flash",
|
||||
"DeepSeek-V4-Flash",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
],
|
||||
)
|
||||
def test_deepseek_v4_models_support_thinking(model):
|
||||
assert _supports_thinking(model) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ["deepseek-v3", "deepseek-chat"])
|
||||
def test_other_deepseek_models_are_not_promoted_to_thinking(model):
|
||||
assert _supports_thinking(model) is False
|
||||
@@ -9,6 +9,8 @@ stream_llm only captured usage when the delta was exactly None / {} /
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import llm_core
|
||||
|
||||
|
||||
@@ -116,7 +118,8 @@ def test_null_choice_chunk_does_not_crash(monkeypatch):
|
||||
|
||||
|
||||
def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
|
||||
# Chunk with both choices:[null] and usage:null — neither field should panic.
|
||||
# Chunk with both choices:[null] and usage:null is a keepalive, not a real
|
||||
# zero-token accounting record.
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({"choices": [None], "usage": None}),
|
||||
@@ -124,6 +127,66 @@ def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
|
||||
]
|
||||
result = _drive(monkeypatch, lines)
|
||||
assert "Hi" in result
|
||||
assert _usage_events(result) == []
|
||||
|
||||
|
||||
def test_empty_usage_object_is_not_reported_as_real_zero_usage(monkeypatch):
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({"choices": [], "usage": {}}),
|
||||
'data: [DONE]',
|
||||
]
|
||||
result = _drive(monkeypatch, lines)
|
||||
assert "Hi" in result
|
||||
assert _usage_events(result) == []
|
||||
|
||||
|
||||
def test_explicit_zero_token_usage_is_preserved(monkeypatch):
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0},
|
||||
}),
|
||||
'data: [DONE]',
|
||||
]
|
||||
usage = _usage_events(_drive(monkeypatch, lines))
|
||||
assert usage == [{"input_tokens": 0, "output_tokens": 0}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"usage_payload",
|
||||
[
|
||||
{"prompt_tokens": None, "completion_tokens": 1},
|
||||
{"prompt_tokens": "bad", "completion_tokens": 1},
|
||||
{"prompt_tokens": -1, "completion_tokens": 1},
|
||||
{"prompt_tokens": True, "completion_tokens": 1},
|
||||
{"prompt_tokens": 1.5, "completion_tokens": 1},
|
||||
{"prompt_tokens": float("inf"), "completion_tokens": 1},
|
||||
],
|
||||
)
|
||||
def test_malformed_token_values_do_not_emit_usage(monkeypatch, usage_payload):
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({"choices": [], "usage": usage_payload}),
|
||||
'data: [DONE]',
|
||||
]
|
||||
result = _drive(monkeypatch, lines)
|
||||
assert "Hi" in result
|
||||
assert _usage_events(result) == []
|
||||
|
||||
|
||||
def test_missing_usage_counterpart_defaults_to_zero(monkeypatch):
|
||||
lines = [
|
||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||
'data: ' + json.dumps({
|
||||
"choices": [],
|
||||
"usage": {"completion_tokens": 2},
|
||||
}),
|
||||
'data: [DONE]',
|
||||
]
|
||||
usage = _usage_events(_drive(monkeypatch, lines))
|
||||
assert usage == [{"input_tokens": 0, "output_tokens": 2}]
|
||||
|
||||
|
||||
def test_null_tool_call_in_delta_is_skipped(monkeypatch):
|
||||
|
||||
@@ -144,10 +144,10 @@ def test_get_default_chat_user_no_prefs_share_disabled_resolves_nothing(monkeypa
|
||||
assert test_data["model"] == "", "Should get empty model"
|
||||
|
||||
|
||||
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_fallbacks(monkeypatch):
|
||||
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
|
||||
"""
|
||||
Non-admin user without personal preferences should resolve to global
|
||||
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
|
||||
defaults for ep_id and model when share_defaults_with_users is enabled.
|
||||
"""
|
||||
|
||||
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True)
|
||||
@@ -158,16 +158,45 @@ def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults_f
|
||||
assert test_data["endpoint_id"] == "global-ep-123", \
|
||||
"Should get global endpoint_id"
|
||||
|
||||
def test_get_default_chat_user_no_prefs_share_enabled_resolves_global_defaults(monkeypatch):
|
||||
def test_get_default_chat_does_not_read_legacy_fallbacks(monkeypatch):
|
||||
"""
|
||||
Non-admin user without personal preferences should resolve to global
|
||||
defaults for ep_id, model, and fallbacks when share_defaults_with_users is enabled.
|
||||
The preserved legacy list must not influence default model resolution.
|
||||
"""
|
||||
|
||||
test_data = _run_get_default_chat_test(monkeypatch, share_defaults_enabled=True, second_endpoint_only=True)
|
||||
class LegacyReadGuard(dict):
|
||||
def get(self, key, default=None):
|
||||
if key == "default_model_fallbacks":
|
||||
raise AssertionError("legacy fallback list was read")
|
||||
return super().get(key, default)
|
||||
|
||||
assert test_data["model"] == "qwen-3.6", \
|
||||
"model should be resolved from global default_model"
|
||||
guarded_settings = LegacyReadGuard({
|
||||
"default_endpoint_id": "global-ep-123",
|
||||
"default_model": "qwen-3.6",
|
||||
"default_model_fallbacks": [
|
||||
{"endpoint_id": "fallback-ep", "model": "fallback-model"}
|
||||
],
|
||||
"share_defaults_with_users": True,
|
||||
})
|
||||
monkeypatch.setattr(model_routes, "_load_settings", lambda: guarded_settings)
|
||||
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda user: LegacyReadGuard({}))
|
||||
|
||||
assert test_data["endpoint_id"] == "fallback-ep", \
|
||||
"Should get global endpoint_id"
|
||||
fake_auth_manager = MagicMock()
|
||||
fake_auth_manager.is_admin = lambda user: False
|
||||
endpoint = _FakeEndpoint(
|
||||
id="global-ep-123",
|
||||
base_url="http://global-endpoint:8000/v1",
|
||||
is_enabled=True,
|
||||
)
|
||||
fake_db = _make_db_session([endpoint], user="regular_user")
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: fake_db)
|
||||
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url)
|
||||
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat")
|
||||
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
get_default_chat = _get_default_chat_route(router)
|
||||
fake_request = _make_request(user="regular_user", auth_manager=fake_auth_manager)
|
||||
|
||||
test_data = get_default_chat(fake_request)
|
||||
|
||||
assert test_data["endpoint_id"] == "global-ep-123"
|
||||
assert test_data["model"] == "qwen-3.6"
|
||||
|
||||
@@ -90,7 +90,7 @@ def test_clear_speech_endpoint_settings_resets_tts_and_stt():
|
||||
}
|
||||
|
||||
|
||||
def test_endpoint_cleanup_removes_primary_and_fallback_references():
|
||||
def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
|
||||
settings = {
|
||||
"default_endpoint_id": "dead",
|
||||
"default_model": "primary",
|
||||
@@ -98,6 +98,9 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
|
||||
{"endpoint_id": "dead", "model": "fallback-a"},
|
||||
{"endpoint_id": "keep", "model": "fallback-b"},
|
||||
],
|
||||
"foreground_model_fallbacks": [
|
||||
{"endpoint_id": "dead", "model": "foreground"},
|
||||
],
|
||||
"utility_model_fallbacks": [{"endpoint_id": "dead", "model": "utility"}],
|
||||
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
|
||||
"stt_provider": "endpoint:dead",
|
||||
@@ -106,14 +109,14 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
|
||||
|
||||
assert _endpoint_settings_using_endpoint(settings, "dead", include_speech=True) == [
|
||||
"Default Model",
|
||||
"Default Model Fallbacks",
|
||||
"Foreground Model Fallbacks",
|
||||
"Utility Model Fallbacks",
|
||||
"Vision Model Fallbacks",
|
||||
"Speech to Text",
|
||||
]
|
||||
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
|
||||
"Default Model",
|
||||
"Default Model Fallbacks",
|
||||
"Foreground Model Fallbacks",
|
||||
"Utility Model Fallbacks",
|
||||
"Vision Model Fallbacks",
|
||||
"Speech to Text",
|
||||
@@ -121,20 +124,31 @@ def test_endpoint_cleanup_removes_primary_and_fallback_references():
|
||||
assert settings["default_endpoint_id"] == ""
|
||||
assert settings["default_model"] == ""
|
||||
assert settings["default_model_fallbacks"] == [
|
||||
{"endpoint_id": "dead", "model": "fallback-a"},
|
||||
{"endpoint_id": "keep", "model": "fallback-b"},
|
||||
]
|
||||
assert settings["foreground_model_fallbacks"] == []
|
||||
assert settings["utility_model_fallbacks"] == []
|
||||
assert settings["vision_model_fallbacks"] == []
|
||||
assert settings["stt_provider"] == "disabled"
|
||||
assert settings["stt_model"] == "base"
|
||||
|
||||
|
||||
def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
|
||||
def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data():
|
||||
scoped = {
|
||||
"foreground_model_fallbacks": [
|
||||
{"endpoint_id": "dead", "model": "ownerless"},
|
||||
],
|
||||
"default_model_fallbacks": [
|
||||
{"endpoint_id": "dead", "model": "legacy-ownerless"},
|
||||
],
|
||||
"_users": {
|
||||
"alice": {
|
||||
"utility_endpoint_id": "dead",
|
||||
"utility_model": "utility",
|
||||
"foreground_model_fallbacks": [
|
||||
{"endpoint_id": "dead", "model": "foreground"},
|
||||
],
|
||||
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
|
||||
},
|
||||
"bob": {
|
||||
@@ -143,10 +157,15 @@ def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
|
||||
},
|
||||
},
|
||||
}
|
||||
assert _clear_user_pref_endpoint_refs(scoped, "dead") == 1
|
||||
assert _clear_user_pref_endpoint_refs(scoped, "dead") == 2
|
||||
assert scoped["foreground_model_fallbacks"] == []
|
||||
assert scoped["default_model_fallbacks"] == [
|
||||
{"endpoint_id": "dead", "model": "legacy-ownerless"},
|
||||
]
|
||||
assert scoped["_users"]["alice"] == {
|
||||
"utility_endpoint_id": "",
|
||||
"utility_model": "",
|
||||
"foreground_model_fallbacks": [],
|
||||
"vision_model_fallbacks": [],
|
||||
}
|
||||
assert scoped["_users"]["bob"]["default_endpoint_id"] == "keep"
|
||||
@@ -154,8 +173,10 @@ def test_endpoint_cleanup_updates_scoped_and_legacy_user_prefs():
|
||||
legacy = {
|
||||
"default_model_fallbacks": [{"endpoint_id": "dead", "model": "chat"}],
|
||||
}
|
||||
assert _clear_user_pref_endpoint_refs(legacy, "dead") == 1
|
||||
assert legacy["default_model_fallbacks"] == []
|
||||
assert _clear_user_pref_endpoint_refs(legacy, "dead") == 0
|
||||
assert legacy["default_model_fallbacks"] == [
|
||||
{"endpoint_id": "dead", "model": "chat"}
|
||||
]
|
||||
|
||||
|
||||
# ── _default_endpoint_needs_assignment (add-endpoint auto-default) ──
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Regression tests for polling endpoints and foreground task interruption."""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
|
||||
|
||||
def _reload_gate():
|
||||
import src.interactive_gate as ig
|
||||
|
||||
importlib.reload(ig)
|
||||
return ig
|
||||
|
||||
|
||||
def test_tasks_runs_recent_is_passive():
|
||||
ig = _reload_gate()
|
||||
|
||||
assert not ig.should_track_interactive_request(
|
||||
"/api/tasks/runs/recent", "GET"
|
||||
)
|
||||
|
||||
|
||||
def test_tasks_runs_recent_does_not_affect_other_task_paths():
|
||||
ig = _reload_gate()
|
||||
|
||||
# A neighboring mutating path must remain interactive.
|
||||
assert ig.should_track_interactive_request(
|
||||
"/api/tasks/runs/recent/something", "POST"
|
||||
)
|
||||
|
||||
|
||||
def test_heartbeat_does_not_stop_background_tasks_when_gate_disabled(monkeypatch):
|
||||
ig = _reload_gate()
|
||||
monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "false")
|
||||
|
||||
stop_calls = []
|
||||
|
||||
async def fake_stop_background_tasks_for_foreground(*, reason):
|
||||
stop_calls.append(reason)
|
||||
|
||||
result = asyncio.run(
|
||||
ig.maybe_stop_background_tasks_for_heartbeat(
|
||||
fake_stop_background_tasks_for_foreground
|
||||
)
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert stop_calls == []
|
||||
|
||||
|
||||
def test_heartbeat_stops_background_tasks_when_gate_enabled(monkeypatch):
|
||||
ig = _reload_gate()
|
||||
monkeypatch.delenv("BACKGROUND_TASK_FOREGROUND_GATE", raising=False)
|
||||
|
||||
stop_calls = []
|
||||
|
||||
async def fake_stop_background_tasks_for_foreground(*, reason):
|
||||
stop_calls.append(reason)
|
||||
|
||||
result = asyncio.run(
|
||||
ig.maybe_stop_background_tasks_for_heartbeat(
|
||||
fake_stop_background_tasks_for_foreground
|
||||
)
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert stop_calls == ["browser heartbeat"]
|
||||
@@ -0,0 +1,933 @@
|
||||
"""Executable regressions for the browser/run-lifecycle review of PR #6020.
|
||||
|
||||
These tests intentionally exercise JavaScript under Node rather than treating
|
||||
``node --check`` or source-string presence as proof that the browser paths are
|
||||
usable. The detached-run replacement case drives the real Python manager.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src import agent_runs
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_CHAT_PATH = _REPO / "static" / "js" / "chat.js"
|
||||
_CHAT = _CHAT_PATH.read_text(encoding="utf-8")
|
||||
_RENDERER = (_REPO / "static" / "js" / "chatRenderer.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
_STREAM_ERRORS_URI = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _extract_source(source: str, start: str, end: str) -> str:
|
||||
"""Slice module source between two anchors, failing loudly if one moved.
|
||||
|
||||
The extracted region ships to Node verbatim, so the anchors must stay
|
||||
unique strings in the module. A refactor that renames or duplicates an
|
||||
anchor fails here with the anchor named, not with an opaque split error.
|
||||
"""
|
||||
assert source.count(start) == 1, f"start anchor not unique in source: {start!r}"
|
||||
tail = source.split(start, 1)[1]
|
||||
assert end in tail, f"end anchor not found after start anchor: {end!r}"
|
||||
return start + tail.split(end, 1)[0]
|
||||
|
||||
|
||||
def _run_node(source: str) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=source,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=_REPO,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
# A few imported browser modules log optional-service status at startup.
|
||||
# Keep the runtime smoke honest while reading only its final JSON result.
|
||||
return json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _chat_smoke_source(extra_source: str) -> str:
|
||||
"""Return chat.js source with its real imports made absolute."""
|
||||
|
||||
def absolute_import(match: re.Match[str]) -> str:
|
||||
relative = match.group("relative")
|
||||
path_part, separator, query = relative.partition("?")
|
||||
target = (_CHAT_PATH.parent / path_part).resolve().as_uri()
|
||||
if separator:
|
||||
target += "?" + query
|
||||
return match.group("prefix") + target + match.group("quote")
|
||||
|
||||
source = re.sub(
|
||||
r"(?P<prefix>from\s+(?P<quote>['\"]))(?P<relative>\./[^'\"]+)(?P=quote)",
|
||||
absolute_import,
|
||||
_CHAT,
|
||||
)
|
||||
source += "\n" + extra_source
|
||||
return source
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_chat_runtime_stream_state_helpers_are_all_callable(tmp_path):
|
||||
"""Import the real module and execute all three rebased-away helpers."""
|
||||
|
||||
module_source = _chat_smoke_source(
|
||||
"""
|
||||
export function __pr6020StreamStateSmoke() {
|
||||
const sid = 'pr6020-runtime-smoke';
|
||||
const controller = { abort() {}, signal: { aborted: false } };
|
||||
const originalGetSessionId = sessionModule.getCurrentSessionId;
|
||||
sessionModule.getCurrentSessionId = () => sid;
|
||||
try {
|
||||
_activeStreams.set(sid, {
|
||||
abortCtrl: controller,
|
||||
holder: { id: 'holder' },
|
||||
lastActivity: 0,
|
||||
});
|
||||
const active = _getForegroundStreamState();
|
||||
const touchedAt = _touchStreamActivity(sid);
|
||||
const synced = _syncForegroundStreamGlobals();
|
||||
return {
|
||||
activeController: active && active.abortCtrl === controller,
|
||||
touched: touchedAt > 0 && _activeStreams.get(sid).lastActivity === touchedAt,
|
||||
synced: synced === active && currentAbort === controller && isStreaming,
|
||||
};
|
||||
} finally {
|
||||
_activeStreams.delete(sid);
|
||||
sessionModule.getCurrentSessionId = originalGetSessionId;
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
module_path = tmp_path / "chat-runtime-smoke.mjs"
|
||||
module_path.write_text(module_source, encoding="utf-8")
|
||||
module_uri = module_path.as_uri()
|
||||
script = f"""
|
||||
globalThis.window = globalThis;
|
||||
globalThis.addEventListener = () => {{}};
|
||||
globalThis.removeEventListener = () => {{}};
|
||||
globalThis.dispatchEvent = () => {{}};
|
||||
globalThis.requestAnimationFrame = () => 0;
|
||||
globalThis.cancelAnimationFrame = () => {{}};
|
||||
globalThis.fetch = async () => ({{
|
||||
ok: false,
|
||||
json: async () => ({{}}),
|
||||
text: async () => '',
|
||||
headers: {{ get() {{ return null; }} }},
|
||||
}});
|
||||
class Element {{
|
||||
constructor() {{
|
||||
this.children = [];
|
||||
this.classList = {{
|
||||
add() {{}}, remove() {{}}, toggle() {{}}, contains() {{ return false; }},
|
||||
}};
|
||||
this.style = {{ setProperty() {{}} }};
|
||||
this.dataset = {{}};
|
||||
}}
|
||||
querySelector() {{ return null; }}
|
||||
querySelectorAll() {{ return []; }}
|
||||
appendChild(child) {{ this.children.push(child); return child; }}
|
||||
addEventListener() {{}}
|
||||
removeEventListener() {{}}
|
||||
}}
|
||||
class HTMLInputElement extends Element {{
|
||||
get value() {{ return this._value || ''; }}
|
||||
set value(value) {{ this._value = value; }}
|
||||
}}
|
||||
globalThis.HTMLInputElement = HTMLInputElement;
|
||||
const root = new Element();
|
||||
globalThis.document = {{
|
||||
body: root,
|
||||
head: root,
|
||||
documentElement: root,
|
||||
getElementById() {{ return null; }},
|
||||
querySelector() {{ return null; }},
|
||||
querySelectorAll() {{ return []; }},
|
||||
createElement(tag) {{ return tag === 'input' ? new HTMLInputElement() : new Element(); }},
|
||||
createTextNode(text) {{ return {{ textContent: text }}; }},
|
||||
addEventListener() {{}},
|
||||
removeEventListener() {{}},
|
||||
}};
|
||||
globalThis.localStorage = {{ getItem() {{ return null; }}, setItem() {{}}, removeItem() {{}} }};
|
||||
globalThis.location = {{}};
|
||||
globalThis.history = {{}};
|
||||
globalThis.MutationObserver = class {{ observe() {{}} }};
|
||||
globalThis.CustomEvent = class {{}};
|
||||
globalThis.Storage = class {{}};
|
||||
const chat = await import({json.dumps(module_uri)});
|
||||
console.log(JSON.stringify(chat.__pr6020StreamStateSmoke()));
|
||||
process.exit(0);
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"activeController": True,
|
||||
"touched": True,
|
||||
"synced": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_canonical_terminal_followed_by_eof_is_not_auto_recovered():
|
||||
"""A persisted terminal marker owns EOF; a plain premature EOF still retries."""
|
||||
|
||||
completion_gate = _extract_source(
|
||||
_CHAT,
|
||||
"if (_streamTerminalError)",
|
||||
"// The final foreground render below is authoritative.",
|
||||
)
|
||||
script = f"""
|
||||
import {{
|
||||
createTerminalStreamError,
|
||||
isRecoverableStreamError,
|
||||
}} from {json.dumps(_STREAM_ERRORS_URI)};
|
||||
function runCompletionGate(canonicalTerminalSaved) {{
|
||||
let _streamTerminalError = null;
|
||||
let _streamSawDone = false;
|
||||
let _canonicalTerminalSaved = canonicalTerminalSaved;
|
||||
try {{
|
||||
{completion_gate}
|
||||
return {{ recovered: false, completed: true }};
|
||||
}} catch (error) {{
|
||||
return {{
|
||||
recovered: isRecoverableStreamError(error),
|
||||
completed: false,
|
||||
terminal: !!error.terminalStreamError,
|
||||
message: error.message,
|
||||
}};
|
||||
}}
|
||||
}}
|
||||
console.log(JSON.stringify({{
|
||||
savedTerminal: runCompletionGate(true),
|
||||
plainEof: runCompletionGate(false),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
# A saved canonical terminal must neither auto-recover nor render as a
|
||||
# clean success: it takes the terminal-error path, whose catch handler
|
||||
# reloads the persisted record.
|
||||
"savedTerminal": {
|
||||
"recovered": False,
|
||||
"completed": False,
|
||||
"terminal": True,
|
||||
"message": "Stream closed after canonical terminal event",
|
||||
},
|
||||
"plainEof": {
|
||||
"recovered": True,
|
||||
"completed": False,
|
||||
"terminal": False,
|
||||
"message": "Stream closed before completion",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_immediate_replacement_closes_subscriber_bound_to_never_started_run():
|
||||
"""Cancellation before _drain's first instruction must still terminalize run 1."""
|
||||
|
||||
session_id = "pr6020-immediate-replacement"
|
||||
agent_runs._RUNS.pop(session_id, None)
|
||||
|
||||
async def never_started():
|
||||
yield 'data: {"delta":"old"}\n\n'
|
||||
|
||||
async def replacement():
|
||||
yield 'data: {"delta":"new"}\n\n'
|
||||
|
||||
first = agent_runs.start(session_id, never_started())
|
||||
first_subscription = asyncio.create_task(
|
||||
_collect_run_events(session_id, first)
|
||||
)
|
||||
# Do not yield between starts: first.task is cancelled before _drain gets
|
||||
# its first instruction, exactly the race a rapid double-send creates.
|
||||
second = agent_runs.start(session_id, replacement())
|
||||
|
||||
assert await asyncio.wait_for(first_subscription, timeout=0.5) == []
|
||||
await asyncio.wait_for(second.task, timeout=0.5)
|
||||
assert first.status == "stopped"
|
||||
assert second.status == "done"
|
||||
|
||||
|
||||
async def _collect_run_events(session_id: str, run: object) -> list[str]:
|
||||
return [event async for event in agent_runs.subscribe(session_id, run)]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_stop_before_response_headers_waits_for_exact_run_identity():
|
||||
"""Never send headerless Stop, but flush the queued Stop once headers arrive."""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
header_capture = _extract_source(
|
||||
_CHAT,
|
||||
"const streamRunId = res.headers.get('X-Odysseus-Run-Id')",
|
||||
"// Mark the chat log busy",
|
||||
)
|
||||
script = f"""
|
||||
const calls = [];
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
|
||||
{state_and_stop}
|
||||
{{
|
||||
const streamSessionId = 'normal-session';
|
||||
const streamGeneration = 1;
|
||||
_streamGenerations.set(streamSessionId, streamGeneration);
|
||||
const res = {{ headers: {{ get(name) {{
|
||||
return name === 'X-Odysseus-Run-Id' ? 'normal-run' : null;
|
||||
}} }} }};
|
||||
{header_capture}
|
||||
}}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const normalHeaderCalls = calls.length;
|
||||
let beforeHeaders;
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
const streamGeneration = 1;
|
||||
_streamGenerations.set(streamSessionId, streamGeneration);
|
||||
_stopExactRun(streamSessionId);
|
||||
beforeHeaders = calls.length;
|
||||
const res = {{ headers: {{ get(name) {{
|
||||
return name === 'X-Odysseus-Run-Id' ? 'run-1' : null;
|
||||
}} }} }};
|
||||
{header_capture}
|
||||
}}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
console.log(JSON.stringify({{
|
||||
normalHeaderCalls,
|
||||
beforeHeaders,
|
||||
calls: calls.map(call => ({{
|
||||
url: call.url,
|
||||
method: call.options.method,
|
||||
runId: call.options.headers['X-Odysseus-Run-Id'],
|
||||
}})),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"normalHeaderCalls": 0,
|
||||
"beforeHeaders": 0,
|
||||
"calls": [
|
||||
{
|
||||
"url": "/api/chat/stop/session-1",
|
||||
"method": "POST",
|
||||
"runId": "run-1",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_timeout_before_response_headers_also_waits_for_exact_run_identity():
|
||||
"""The automatic timeout must preserve the POST until its run id arrives."""
|
||||
|
||||
script = f"""
|
||||
{_timeout_harness_prelude()}
|
||||
callbacks[0]();
|
||||
const beforeHeaders = {{ aborted: abortCtrl.signal.aborted, calls: calls.length }};
|
||||
_rememberStreamRunId(streamSessionId, 'run-1', streamGeneration);
|
||||
await Promise.resolve();
|
||||
console.log(JSON.stringify({{
|
||||
beforeHeaders,
|
||||
afterHeaders: {{
|
||||
aborted: abortCtrl.signal.aborted,
|
||||
runId: calls[0] && calls[0].options.headers['X-Odysseus-Run-Id'],
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"beforeHeaders": {"aborted": False, "calls": 0},
|
||||
"afterHeaders": {"aborted": True, "runId": "run-1"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_resend_preserves_queued_stop_until_old_run_identity_arrives():
|
||||
"""A replacement must not sever the superseded POST's identity channel.
|
||||
|
||||
The queued Stop stays generation-tagged and fires from the OLD send's own
|
||||
header arrival, so the old run is cancelled even when the replacement dies
|
||||
before its POST reaches the server (which is what would otherwise cancel
|
||||
it). The old run id must not leak into the replacement's identity map.
|
||||
"""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
resend_reset = _extract_source(
|
||||
_CHAT,
|
||||
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
|
||||
"_sendInFlight = false;",
|
||||
)
|
||||
header_capture = _extract_source(
|
||||
_CHAT,
|
||||
"const streamRunId = res.headers.get('X-Odysseus-Run-Id')",
|
||||
"// Mark the chat log busy",
|
||||
)
|
||||
script = f"""
|
||||
const calls = [];
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
|
||||
{state_and_stop}
|
||||
const oldCtrl = {{
|
||||
_reason: '',
|
||||
signal: {{ aborted: false }},
|
||||
abort() {{ this.signal.aborted = true; }},
|
||||
}};
|
||||
// Old send (generation 1) queues a Stop before its headers arrive.
|
||||
_streamGenerations.set('session-1', 1);
|
||||
const oldGeneration = 1;
|
||||
_stopExactRun('session-1', oldCtrl);
|
||||
const queuedBefore = _pendingRunStops.has('session-1:1');
|
||||
// Replacement send starts: bumps the generation, leaves the queued Stop.
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
{resend_reset}
|
||||
}}
|
||||
// The replacement is ALSO stopped before its headers arrive: both
|
||||
// sends' cancellation intents must coexist, neither displacing the
|
||||
// other (a single session-keyed slot loses the old send's Stop, and
|
||||
// with it the only cancel for that run if this replacement dies
|
||||
// before its own POST reaches the server).
|
||||
const newCtrl = {{
|
||||
_reason: '',
|
||||
signal: {{ aborted: false }},
|
||||
abort() {{ this.signal.aborted = true; }},
|
||||
}};
|
||||
_stopExactRun('session-1', newCtrl);
|
||||
const afterResend = {{
|
||||
oldQueuedKept: _pendingRunStops.has('session-1:1'),
|
||||
newQueued: _pendingRunStops.has('session-1:2'),
|
||||
oldAborted: oldCtrl.signal.aborted,
|
||||
generation: _streamGenerations.get('session-1'),
|
||||
}};
|
||||
// The old POST's headers finally arrive: its queued Stop fires with its
|
||||
// own run id, and the old controller aborts.
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
const streamGeneration = oldGeneration;
|
||||
const res = {{ headers: {{ get(name) {{
|
||||
return name === 'X-Odysseus-Run-Id' ? 'old-run' : null;
|
||||
}} }} }};
|
||||
{header_capture}
|
||||
}}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
console.log(JSON.stringify({{
|
||||
queuedBefore,
|
||||
afterResend,
|
||||
afterOldHeaders: {{
|
||||
oldQueued: _pendingRunStops.has('session-1:1'),
|
||||
newQueuedKept: _pendingRunStops.has('session-1:2'),
|
||||
oldAborted: oldCtrl.signal.aborted,
|
||||
oldReason: oldCtrl._reason,
|
||||
newAborted: newCtrl.signal.aborted,
|
||||
currentRunIdPolluted: _streamRunIds.has('session-1'),
|
||||
stopCalls: calls.map(call => ({{
|
||||
url: call.url,
|
||||
runId: call.options.headers['X-Odysseus-Run-Id'],
|
||||
}})),
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"queuedBefore": True,
|
||||
"afterResend": {
|
||||
"oldQueuedKept": True,
|
||||
"newQueued": True,
|
||||
"oldAborted": False,
|
||||
"generation": 2,
|
||||
},
|
||||
"afterOldHeaders": {
|
||||
"oldQueued": False,
|
||||
# The replacement's own queued Stop must survive the old send's
|
||||
# flush untouched.
|
||||
"newQueuedKept": True,
|
||||
"oldAborted": True,
|
||||
"oldReason": "user-stop",
|
||||
"newAborted": False,
|
||||
# The stale send's run id must not become the replacement's
|
||||
# identity, but its exact Stop must still go out.
|
||||
"currentRunIdPolluted": False,
|
||||
"stopCalls": [
|
||||
{"url": "/api/chat/stop/session-1", "runId": "old-run"}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_superseded_stream_cleanup_leaves_replacement_state_alone():
|
||||
"""A stale send's finally must not clear state the replacement owns.
|
||||
|
||||
Ownership is decided by generation, which the replacement bumps at its
|
||||
very first synchronous step — so the guard holds even in the window
|
||||
BEFORE the replacement registers its own stream entry (where the old
|
||||
finally still sees its own registration and controller identity alone
|
||||
would call it the owner).
|
||||
"""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
finally_cleanup = _extract_source(
|
||||
_CHAT,
|
||||
"const _ownsStreamState =",
|
||||
"// Streaming done — let screen readers announce",
|
||||
)
|
||||
script = f"""
|
||||
let currentAbort = null;
|
||||
let isStreaming = false;
|
||||
let currentHolder = null;
|
||||
let _sendInFlight = false;
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
{state_and_stop}
|
||||
function runCleanup(abortCtrl, streamGeneration) {{
|
||||
const streamSessionId = 'session-1';
|
||||
const _sendState = {{ generation: streamGeneration, abortCtrl }};
|
||||
{finally_cleanup}
|
||||
return _ownsStreamState;
|
||||
}}
|
||||
const oldCtrl = {{ signal: {{ aborted: true }}, abort() {{}} }};
|
||||
const newCtrl = {{ signal: {{ aborted: false }}, abort() {{}} }};
|
||||
// Pre-registration supersession: the replacement bumped the generation
|
||||
// and set the session id, but has NOT registered its stream entry yet —
|
||||
// the old send's own entry is still the one in the map.
|
||||
_streamGenerations.set('session-1', 2);
|
||||
_streamSessionId = 'session-1';
|
||||
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
|
||||
_pendingRunStops.set('session-1:2', newCtrl);
|
||||
const preRegOwns = runCleanup(oldCtrl, 1);
|
||||
const afterPreReg = {{
|
||||
ownEntryRemoved: !_activeStreams.has('session-1'),
|
||||
replacementPendingKept: _pendingRunStops.has('session-1:2'),
|
||||
sessionKept: _streamSessionId === 'session-1',
|
||||
}};
|
||||
// Post-registration supersession: the replacement's entry is in the map.
|
||||
_activeStreams.set('session-1', {{ abortCtrl: newCtrl, holder: null, lastActivity: 2 }});
|
||||
const postRegOwns = runCleanup(oldCtrl, 1);
|
||||
const afterPostReg = {{
|
||||
replacementRegistrationKept: _activeStreams.has('session-1'),
|
||||
replacementPendingKept: _pendingRunStops.has('session-1:2'),
|
||||
sessionKept: _streamSessionId === 'session-1',
|
||||
}};
|
||||
// Owner: the current-generation send cleans up normally.
|
||||
const ownerOwns = runCleanup(newCtrl, 2);
|
||||
const afterOwner = {{
|
||||
registered: _activeStreams.has('session-1'),
|
||||
pendingKept: _pendingRunStops.has('session-1:2'),
|
||||
sessionCleared: _streamSessionId === null,
|
||||
}};
|
||||
console.log(JSON.stringify({{
|
||||
preRegOwns, afterPreReg, postRegOwns, afterPostReg, ownerOwns, afterOwner,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"preRegOwns": False,
|
||||
"afterPreReg": {
|
||||
"ownEntryRemoved": True,
|
||||
"replacementPendingKept": True,
|
||||
"sessionKept": True,
|
||||
},
|
||||
"postRegOwns": False,
|
||||
"afterPostReg": {
|
||||
"replacementRegistrationKept": True,
|
||||
"replacementPendingKept": True,
|
||||
"sessionKept": True,
|
||||
},
|
||||
"ownerOwns": True,
|
||||
"afterOwner": {
|
||||
"registered": False,
|
||||
"pendingKept": False,
|
||||
"sessionCleared": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_real_reservation_supersedes_and_stale_cleanup_keeps_gate_closed():
|
||||
"""Drive the REAL send-commit reservation, then a stale send's cleanup.
|
||||
|
||||
The reservation is synchronous, so the previous send is superseded before
|
||||
any await runs; its cleanup must then neither clear session state nor
|
||||
resync the foreground globals (a stale sync would set isStreaming false
|
||||
while _sendInFlight is already false, reopening the send gate before the
|
||||
replacement registers).
|
||||
"""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
reservation = _extract_source(
|
||||
_CHAT,
|
||||
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
|
||||
"_sendInFlight = false;",
|
||||
)
|
||||
finally_cleanup = _extract_source(
|
||||
_CHAT,
|
||||
"const _ownsStreamState =",
|
||||
"// Streaming done — let screen readers announce",
|
||||
)
|
||||
script = f"""
|
||||
let currentAbort = null;
|
||||
let isStreaming = true;
|
||||
let currentHolder = null;
|
||||
let _sendInFlight = false;
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
{state_and_stop}
|
||||
const oldCtrl = {{ signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
|
||||
// Old send (generation 1) is mid-stream and registered.
|
||||
_streamGenerations.set('session-1', 1);
|
||||
_streamSessionId = 'session-1';
|
||||
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
|
||||
// Replacement commits: run the REAL reservation block synchronously.
|
||||
let installed;
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
{reservation}
|
||||
installed = {{ generation: streamGeneration, sendState: _sendState }};
|
||||
}}
|
||||
const afterReservation = {{
|
||||
generation: _streamGenerations.get('session-1'),
|
||||
sendStateInstalled: _sendStates.get('session-1') === installed.sendState,
|
||||
controllerPending: installed.sendState.abortCtrl === null,
|
||||
}};
|
||||
// Old send's cleanup runs mid-preflight (before the replacement
|
||||
// registers): it must treat itself as superseded.
|
||||
let staleOwns;
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
const streamGeneration = 1;
|
||||
const abortCtrl = oldCtrl;
|
||||
const _sendState = {{ generation: 1, abortCtrl: oldCtrl }};
|
||||
{finally_cleanup}
|
||||
staleOwns = _ownsStreamState;
|
||||
}}
|
||||
console.log(JSON.stringify({{
|
||||
afterReservation,
|
||||
staleOwns,
|
||||
afterStaleCleanup: {{
|
||||
sessionKept: _streamSessionId === 'session-1',
|
||||
sendStateKept: _sendStates.get('session-1') === installed.sendState,
|
||||
gateStillClosed: isStreaming === true,
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"afterReservation": {
|
||||
"generation": 2,
|
||||
"sendStateInstalled": True,
|
||||
"controllerPending": True,
|
||||
},
|
||||
"staleOwns": False,
|
||||
"afterStaleCleanup": {
|
||||
"sessionKept": True,
|
||||
"sendStateKept": True,
|
||||
# isStreaming untouched because the superseded send skipped the
|
||||
# foreground resync entirely.
|
||||
"gateStillClosed": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_stale_preflight_bails_before_creating_controller():
|
||||
"""A send superseded during preflight must not proceed to register/POST."""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
preflight_gate = _extract_source(
|
||||
_CHAT, "// Superseded during preflight", "currentAbort = abortCtrl;"
|
||||
) + "currentAbort = abortCtrl;"
|
||||
script = f"""
|
||||
let currentAbort = null;
|
||||
let isStreaming = false;
|
||||
let currentHolder = null;
|
||||
let _sendInFlight = false;
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const window = {{}};
|
||||
const document = {{
|
||||
createElement() {{ return {{ style: {{}}, textContent: '' }}; }},
|
||||
}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
{state_and_stop}
|
||||
function runPreflightGate(streamGeneration, _sendState, _userMsgEl) {{
|
||||
const streamSessionId = 'session-1';
|
||||
let abortCtrl = null;
|
||||
{preflight_gate}
|
||||
return abortCtrl;
|
||||
}}
|
||||
// Stale: generation 1 resumes after generation 2 reserved the session.
|
||||
// Its optimistic user bubble must be marked undelivered, not left as a
|
||||
// ghost that looks sent.
|
||||
_streamGenerations.set('session-1', 2);
|
||||
const staleState = {{ generation: 1, abortCtrl: null }};
|
||||
const staleBubble = {{
|
||||
parentNode: {{}},
|
||||
notes: [],
|
||||
appendChild(node) {{ this.notes.push(node.textContent); }},
|
||||
}};
|
||||
const staleResult = runPreflightGate(1, staleState, staleBubble);
|
||||
// Current: generation 2 proceeds and wires its controller.
|
||||
const currentState = {{ generation: 2, abortCtrl: null }};
|
||||
const currentBubble = {{
|
||||
parentNode: {{}},
|
||||
notes: [],
|
||||
appendChild(node) {{ this.notes.push(node.textContent); }},
|
||||
}};
|
||||
const currentResult = runPreflightGate(2, currentState, currentBubble);
|
||||
console.log(JSON.stringify({{
|
||||
staleBailed: staleResult === undefined,
|
||||
staleControllerNever: staleState.abortCtrl === null,
|
||||
staleBubbleNotes: staleBubble.notes,
|
||||
currentProceeded: !!currentResult,
|
||||
currentWired: currentState.abortCtrl === currentResult && currentAbort === currentResult,
|
||||
currentBubbleNotes: currentBubble.notes,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"staleBailed": True,
|
||||
"staleControllerNever": True,
|
||||
"staleBubbleNotes": ["[Not sent — superseded by a newer message]"],
|
||||
"currentProceeded": True,
|
||||
"currentWired": True,
|
||||
"currentBubbleNotes": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_stop_during_replacement_preflight_never_borrows_old_controller():
|
||||
"""Stop must take the current send's controller from its send state.
|
||||
|
||||
During the replacement's preflight the stream registry still holds the
|
||||
superseded send's entry; borrowing that controller would abort the only
|
||||
identity channel able to name the old run while queueing a Stop for the
|
||||
new one. A committed-but-pre-POST send has a null controller: the Stop
|
||||
queues under the new generation and nothing is aborted yet.
|
||||
"""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
abort_current = _extract_source(
|
||||
_CHAT, "export function abortCurrentRequest", "// ── Stall watchdog"
|
||||
).replace("export function", "function")
|
||||
reservation = _extract_source(
|
||||
_CHAT,
|
||||
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
|
||||
"_sendInFlight = false;",
|
||||
)
|
||||
script = f"""
|
||||
let currentAbort = null;
|
||||
let isStreaming = false;
|
||||
let currentHolder = null;
|
||||
let _sendInFlight = false;
|
||||
function _setForegroundChatBusy() {{}}
|
||||
const calls = [];
|
||||
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
{state_and_stop}
|
||||
{abort_current}
|
||||
const oldCtrl = {{ signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
|
||||
// Generation 1 is registered, streaming, and its run id is KNOWN — the
|
||||
// exact window daybreak probed: a Stop right after the replacement
|
||||
// commits must not consume the old run identity.
|
||||
_streamGenerations.set('session-1', 1);
|
||||
_streamRunIds.set('session-1', 'old-run');
|
||||
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
|
||||
currentAbort = oldCtrl;
|
||||
// Replacement (generation 2) commits via the REAL reservation block; no
|
||||
// controller exists yet and the model-switch await has not resolved.
|
||||
{{
|
||||
const streamSessionId = 'session-1';
|
||||
{reservation}
|
||||
}}
|
||||
abortCurrentRequest(true);
|
||||
const preRegistration = {{
|
||||
oldRunIdCleared: !_streamRunIds.has('session-1'),
|
||||
queuedForNew: _pendingRunStops.has('session-1:2'),
|
||||
queuedController: _pendingRunStops.get('session-1:2') || null,
|
||||
oldAborted: oldCtrl.signal.aborted,
|
||||
stopCalls: calls.length,
|
||||
}};
|
||||
// Normal case: the current send's own controller, run id known.
|
||||
const ownCtrl = {{ _reason: '', signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
|
||||
_sendStates.set('session-1', {{ generation: 2, abortCtrl: ownCtrl }});
|
||||
_streamRunIds.set('session-1', 'run-2');
|
||||
abortCurrentRequest(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
console.log(JSON.stringify({{
|
||||
preRegistration,
|
||||
normal: {{
|
||||
ownAborted: ownCtrl.signal.aborted,
|
||||
oldStillUntouched: oldCtrl.signal.aborted,
|
||||
stopRunId: calls[0] && calls[0].options.headers['X-Odysseus-Run-Id'],
|
||||
}},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"preRegistration": {
|
||||
# The old run identity dies at reservation: the Stop queues for
|
||||
# the NEW send instead of firing against the old run and skipping
|
||||
# the queue entirely.
|
||||
"oldRunIdCleared": True,
|
||||
"queuedForNew": True,
|
||||
"queuedController": None,
|
||||
"oldAborted": False,
|
||||
"stopCalls": 0,
|
||||
},
|
||||
"normal": {
|
||||
"ownAborted": True,
|
||||
"oldStillUntouched": False,
|
||||
"stopRunId": "run-2",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_timeout_grace_hard_aborts_when_run_identity_never_arrives():
|
||||
"""A POST hung before headers is still cancelled by the timeout's grace."""
|
||||
|
||||
script = f"""
|
||||
{_timeout_harness_prelude()}
|
||||
callbacks[0]();
|
||||
const afterTimeout = {{ aborted: abortCtrl.signal.aborted, pending: callbacks.length }};
|
||||
callbacks[1]();
|
||||
console.log(JSON.stringify({{
|
||||
afterTimeout,
|
||||
afterGrace: {{ aborted: abortCtrl.signal.aborted, stopCalls: calls.length }},
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
"afterTimeout": {"aborted": False, "pending": 2},
|
||||
"afterGrace": {"aborted": True, "stopCalls": 0},
|
||||
}
|
||||
|
||||
|
||||
def _timeout_harness_prelude() -> str:
|
||||
"""Shared Node harness: real stop/state and timeout blocks, fake timers."""
|
||||
|
||||
state_and_stop = _extract_source(
|
||||
_CHAT, "const _backgroundStreams", "// Sources box builder"
|
||||
)
|
||||
timeout_setup = _extract_source(
|
||||
_CHAT, "timeoutId = setTimeout(() =>", "}, timeoutMs);"
|
||||
) + "}, timeoutMs);"
|
||||
return f"""
|
||||
const calls = [];
|
||||
const callbacks = [];
|
||||
function _setForegroundChatBusy() {{}}
|
||||
function setTimeout(callback) {{ callbacks.push(callback); return 1; }}
|
||||
const window = {{}};
|
||||
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
|
||||
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
|
||||
const RUN_ID_ABORT_GRACE_MS = 2000;
|
||||
{state_and_stop}
|
||||
const streamSessionId = 'session-1';
|
||||
const streamGeneration = 1;
|
||||
_streamGenerations.set(streamSessionId, streamGeneration);
|
||||
const timeoutMs = 1;
|
||||
let timeoutId;
|
||||
let timedOut = false;
|
||||
const abortCtrl = {{
|
||||
_reason: '',
|
||||
signal: {{ aborted: false }},
|
||||
abort() {{ this.signal.aborted = true; }},
|
||||
}};
|
||||
{timeout_setup}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_cost_ledger_serializes_stale_cross_tab_writers():
|
||||
"""A stale writer must merge, not overwrite a distinct run recorded by a peer."""
|
||||
|
||||
ledger = _extract_source(
|
||||
_RENDERER, "const _COST_KEY", "/** Create a timestamp span"
|
||||
).replace("export function", "function")
|
||||
script = f"""
|
||||
const state = {{}};
|
||||
let triggerPeerWrite = true;
|
||||
let lockTail = Promise.resolve();
|
||||
const navigator = {{ locks: {{
|
||||
request(_name, callback) {{
|
||||
const next = lockTail.then(callback);
|
||||
lockTail = next.catch(() => {{}});
|
||||
return next;
|
||||
}},
|
||||
}} }};
|
||||
const window = {{ sessionModule: {{ getCurrentSessionId() {{ return 'session'; }} }} }};
|
||||
const document = {{ getElementById() {{ return null; }} }};
|
||||
function _metricsBillableCost(metrics) {{ return metrics.testCost; }}
|
||||
const peerMetrics = {{ testCost: 0.22, _costRecordId: 'run-b' }};
|
||||
let tabA;
|
||||
let tabB;
|
||||
const localStorage = {{
|
||||
getItem(key) {{
|
||||
const staleSnapshot = state[key] || null;
|
||||
if (key === 'ody-session-cost-runs' && triggerPeerWrite) {{
|
||||
triggerPeerWrite = false;
|
||||
tabB.recordSessionMetricsCost(peerMetrics, 'session');
|
||||
}}
|
||||
return staleSnapshot;
|
||||
}},
|
||||
setItem(key, value) {{ state[key] = value; }},
|
||||
}};
|
||||
function createTab() {{
|
||||
{ledger}
|
||||
return {{ recordSessionMetricsCost }};
|
||||
}}
|
||||
tabA = createTab();
|
||||
tabB = createTab();
|
||||
const metricsA = {{ testCost: 0.11, _costRecordId: 'run-a' }};
|
||||
tabA.recordSessionMetricsCost(metricsA, 'session');
|
||||
const queued = {{
|
||||
recorded: !!metricsA._costRecorded,
|
||||
pending: !!metricsA._costRecordPending,
|
||||
}};
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await lockTail;
|
||||
const runs = JSON.parse(state['ody-session-cost-runs'] || '{{}}').session || {{}};
|
||||
console.log(JSON.stringify({{
|
||||
queued,
|
||||
settled: {{
|
||||
recorded: !!metricsA._costRecorded,
|
||||
pending: !!metricsA._costRecordPending,
|
||||
}},
|
||||
runs,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert _run_node(script) == {
|
||||
# Recorded must not be claimed while the write only sits queued behind
|
||||
# the lock; it flips once the write has actually run.
|
||||
"queued": {"recorded": False, "pending": True},
|
||||
"settled": {"recorded": True, "pending": False},
|
||||
"runs": {"run-a": 0.11, "run-b": 0.22},
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Executable regression coverage for behavior lost in PR #6020's rebase."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import src.agent_loop as agent_loop
|
||||
|
||||
|
||||
ODY_QWEN = "odysseus-qwen3-4b"
|
||||
NOTES_TOOLS = {
|
||||
"manage_notes",
|
||||
"manage_calendar",
|
||||
"manage_tasks",
|
||||
"ask_user",
|
||||
"update_plan",
|
||||
}
|
||||
|
||||
|
||||
def _collect(generator):
|
||||
async def _run():
|
||||
return [chunk async for chunk in generator]
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def _events(chunks):
|
||||
return [
|
||||
json.loads(chunk[6:])
|
||||
for chunk in chunks
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]")
|
||||
]
|
||||
|
||||
|
||||
def _install_route_probe(monkeypatch):
|
||||
prompt_calls = []
|
||||
stream_calls = []
|
||||
|
||||
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
|
||||
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
|
||||
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
|
||||
monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
|
||||
monkeypatch.setattr(
|
||||
agent_loop,
|
||||
"_agent_route_tool_mode",
|
||||
lambda *args, **kwargs: (True, False, False),
|
||||
)
|
||||
|
||||
def fake_build(
|
||||
messages,
|
||||
model,
|
||||
_active_document,
|
||||
_mcp_mgr,
|
||||
disabled_tools=None,
|
||||
**kwargs,
|
||||
):
|
||||
prompt_calls.append(
|
||||
{
|
||||
"model": model,
|
||||
"relevant_tools": set(kwargs.get("relevant_tools") or set()),
|
||||
"disabled_tools": set(disabled_tools or set()),
|
||||
"workspace": kwargs.get("workspace"),
|
||||
}
|
||||
)
|
||||
return (list(messages), [])
|
||||
|
||||
async def fake_stream(_candidates, _messages, **kwargs):
|
||||
stream_calls.append(kwargs)
|
||||
yield 'data: {"delta": "ok"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
monkeypatch.setattr(agent_loop, "_build_system_prompt", fake_build)
|
||||
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
|
||||
return prompt_calls, stream_calls
|
||||
|
||||
|
||||
def _run_probe(messages, *, relevant_tools, **kwargs):
|
||||
return _collect(
|
||||
agent_loop.stream_agent_loop(
|
||||
"https://api.example/v1",
|
||||
kwargs.pop("model", ODY_QWEN),
|
||||
messages,
|
||||
max_rounds=1,
|
||||
relevant_tools=set(relevant_tools),
|
||||
_is_teacher_run=True,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_odysseus_notes_mode_clamps_and_reenables_all_personal_managers(monkeypatch):
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Add buy milk to my notes."}],
|
||||
relevant_tools={"bash", "manage_notes", "manage_calendar", "manage_tasks"},
|
||||
disabled_tools={"manage_notes", "manage_calendar", "manage_tasks"},
|
||||
)
|
||||
|
||||
route = prompt_calls[0]
|
||||
assert route["relevant_tools"] == NOTES_TOOLS
|
||||
assert route["disabled_tools"].isdisjoint(
|
||||
{"manage_notes", "manage_calendar", "manage_tasks"}
|
||||
)
|
||||
|
||||
|
||||
def test_odysseus_general_mode_disables_every_tool(monkeypatch):
|
||||
from src.tool_policy import known_tool_names
|
||||
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Explain the CAP theorem."}],
|
||||
relevant_tools={"bash", "manage_notes", "ask_user"},
|
||||
)
|
||||
|
||||
route = prompt_calls[0]
|
||||
assert route["relevant_tools"] == set()
|
||||
assert known_tool_names() <= route["disabled_tools"]
|
||||
|
||||
|
||||
def test_odysseus_calendar_intent_uses_notes_mode(monkeypatch):
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Add lunch tomorrow to my calendar."}],
|
||||
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
|
||||
)
|
||||
|
||||
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
|
||||
|
||||
|
||||
def test_odysseus_calendar_followup_keeps_notes_mode(monkeypatch):
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
messages = [
|
||||
{"role": "user", "content": "Add lunch tomorrow to my calendar."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Done.",
|
||||
"metadata": {
|
||||
"tool_events": [
|
||||
{
|
||||
"tool": "manage_calendar",
|
||||
"command": '{"action":"create_event","summary":"Lunch"}',
|
||||
"output": "Created event evt-123 at noon.",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
{"role": "user", "content": "Move it to 3pm."},
|
||||
]
|
||||
|
||||
_run_probe(
|
||||
messages,
|
||||
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
|
||||
)
|
||||
|
||||
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
|
||||
|
||||
|
||||
def test_agent_route_passes_workspace_to_system_prompt(monkeypatch):
|
||||
prompt_calls, _ = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Fix the failing test in this project."}],
|
||||
model="gpt-4o",
|
||||
relevant_tools={"bash", "read_file", "apply_patch"},
|
||||
workspace="/tmp/example-repo",
|
||||
)
|
||||
|
||||
assert prompt_calls[0]["workspace"] == "/tmp/example-repo"
|
||||
|
||||
|
||||
def test_odysseus_qwen_temperature_is_capped_for_agent_requests(monkeypatch):
|
||||
_, stream_calls = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Add buy milk to my notes."}],
|
||||
relevant_tools={"manage_notes"},
|
||||
temperature=1.2,
|
||||
)
|
||||
|
||||
assert stream_calls[0]["temperature"] == 0.2
|
||||
|
||||
|
||||
def test_qwen_fallback_candidate_gets_capped_temperature(monkeypatch):
|
||||
"""A non-qwen primary must not leak its temperature into a qwen fallback."""
|
||||
|
||||
_, stream_calls = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Explain the CAP theorem."}],
|
||||
model="gpt-4o",
|
||||
relevant_tools={"bash"},
|
||||
temperature=1.2,
|
||||
fallbacks=[("https://qwen.example/v1", ODY_QWEN, {})],
|
||||
)
|
||||
|
||||
assert stream_calls[0]["temperature"] == 1.2
|
||||
factory = stream_calls[0]["candidate_request_factory"]
|
||||
request = asyncio.run(factory(1, "https://qwen.example/v1", ODY_QWEN, {}))
|
||||
assert request["kwargs"]["temperature"] == 0.2
|
||||
|
||||
|
||||
def test_non_qwen_fallback_keeps_requested_temperature(monkeypatch):
|
||||
"""A qwen primary's 0.2 cap must not leak into a non-qwen fallback."""
|
||||
|
||||
_, stream_calls = _install_route_probe(monkeypatch)
|
||||
|
||||
_run_probe(
|
||||
[{"role": "user", "content": "Add buy milk to my notes."}],
|
||||
relevant_tools={"manage_notes"},
|
||||
temperature=1.2,
|
||||
fallbacks=[("https://backup.example/v1", "gpt-4o", {})],
|
||||
)
|
||||
|
||||
assert stream_calls[0]["temperature"] == 0.2
|
||||
factory = stream_calls[0]["candidate_request_factory"]
|
||||
request = asyncio.run(factory(1, "https://backup.example/v1", "gpt-4o", {}))
|
||||
assert request["kwargs"]["temperature"] == 1.2
|
||||
|
||||
|
||||
def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
|
||||
"""The answering candidate's notes mode must unblock the managers for
|
||||
execution, not just enable them in its own route schemas."""
|
||||
|
||||
_install_route_probe(monkeypatch)
|
||||
stream_round = 0
|
||||
resolve_round = 0
|
||||
seen_exec = {}
|
||||
|
||||
async def fake_stream(_candidates, _messages, **kwargs):
|
||||
nonlocal stream_round
|
||||
stream_round += 1
|
||||
if stream_round == 1:
|
||||
yield (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "fallback",
|
||||
"answered_by": ODY_QWEN,
|
||||
"candidate_index": 1,
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
)
|
||||
yield 'data: {"delta": "Adding the note."}\n\n'
|
||||
else:
|
||||
yield 'data: {"delta": "Done."}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
def fake_resolve(*args, **kwargs):
|
||||
nonlocal resolve_round
|
||||
resolve_round += 1
|
||||
if resolve_round == 1:
|
||||
return ([agent_loop.ToolBlock("manage_notes", "{}")], False, [])
|
||||
return ([], False, [])
|
||||
|
||||
async def fake_execute(block, *args, **kwargs):
|
||||
# Execution is the consumer daybreak's probe showed rejecting the
|
||||
# managers: it receives the shared disabled_tools set, not the
|
||||
# answering route's own tool state.
|
||||
seen_exec["disabled_tools"] = set(kwargs.get("disabled_tools") or [])
|
||||
return ("manage_notes: saved", {"output": "noted", "exit_code": 0})
|
||||
|
||||
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
|
||||
monkeypatch.setattr(agent_loop, "_resolve_tool_blocks", fake_resolve)
|
||||
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
|
||||
|
||||
_collect(
|
||||
agent_loop.stream_agent_loop(
|
||||
"https://api.example/v1",
|
||||
"gpt-4o",
|
||||
[{"role": "user", "content": "Add buy milk to my notes."}],
|
||||
max_rounds=2,
|
||||
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
|
||||
disabled_tools={"manage_notes", "manage_calendar", "manage_tasks"},
|
||||
fallbacks=[("https://qwen.example/v1", ODY_QWEN, {})],
|
||||
_is_teacher_run=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert seen_exec["disabled_tools"].isdisjoint(
|
||||
{"manage_notes", "manage_calendar", "manage_tasks"}
|
||||
)
|
||||
|
||||
|
||||
def test_persisted_mcp_tool_event_keeps_description_and_resolved_name(monkeypatch):
|
||||
_install_route_probe(monkeypatch)
|
||||
stream_round = 0
|
||||
resolve_round = 0
|
||||
|
||||
async def fake_stream(_candidates, _messages, **kwargs):
|
||||
nonlocal stream_round
|
||||
stream_round += 1
|
||||
if stream_round == 1:
|
||||
yield 'data: {"delta": "Calling calendar."}\n\n'
|
||||
else:
|
||||
yield 'data: {"delta": "Finished."}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
def fake_resolve(*args, **kwargs):
|
||||
nonlocal resolve_round
|
||||
resolve_round += 1
|
||||
if resolve_round == 1:
|
||||
return ([agent_loop.ToolBlock("mcp", "{}")], False, [])
|
||||
return ([], False, [])
|
||||
|
||||
async def fake_execute(block, *args, **kwargs):
|
||||
assert block.tool_type == "mcp"
|
||||
return (
|
||||
"mcp__calendar__create_event: created team sync",
|
||||
{"output": "Created event evt-456.", "exit_code": 0},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
|
||||
monkeypatch.setattr(agent_loop, "_resolve_tool_blocks", fake_resolve)
|
||||
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
|
||||
|
||||
chunks = _collect(
|
||||
agent_loop.stream_agent_loop(
|
||||
"https://api.example/v1",
|
||||
"gpt-4o",
|
||||
[{"role": "user", "content": "Create the team sync event."}],
|
||||
max_rounds=2,
|
||||
relevant_tools={"mcp"},
|
||||
_is_teacher_run=True,
|
||||
)
|
||||
)
|
||||
metrics = next(
|
||||
event["data"] for event in _events(chunks) if event.get("type") == "metrics"
|
||||
)
|
||||
persisted = metrics["tool_events"][0]
|
||||
|
||||
assert persisted["tool"] == "mcp__calendar__create_event"
|
||||
assert persisted["desc"] == "mcp__calendar__create_event: created team sync"
|
||||
@@ -17,4 +17,26 @@ def test_load_keeps_object_prefs_file(tmp_path, monkeypatch):
|
||||
prefs_file.write_text(json.dumps({"theme": "dark"}), encoding="utf-8")
|
||||
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||
|
||||
assert prefs_routes._load_for_user("alice") == {"theme": "dark"}
|
||||
assert prefs_routes._load_for_user(None) == {"theme": "dark"}
|
||||
assert prefs_routes._load_for_user("alice") == {}
|
||||
|
||||
|
||||
def test_named_preference_write_does_not_copy_flat_fallback_consent(tmp_path, monkeypatch):
|
||||
prefs_file = tmp_path / "user_prefs.json"
|
||||
prefs_file.write_text(json.dumps({
|
||||
"theme": "light",
|
||||
"foreground_fallback_enabled": True,
|
||||
"foreground_model_fallbacks": [
|
||||
{"endpoint_id": "legacy-single-user", "model": "legacy-model"},
|
||||
],
|
||||
}), encoding="utf-8")
|
||||
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||
|
||||
bob = prefs_routes._load_for_user("bob")
|
||||
bob["theme"] = "dark"
|
||||
prefs_routes._save_for_user("bob", bob)
|
||||
|
||||
raw = prefs_routes._load()
|
||||
assert raw["_users"] == {"bob": {"theme": "dark"}}
|
||||
assert raw["foreground_fallback_enabled"] is True
|
||||
assert raw["foreground_model_fallbacks"][0]["endpoint_id"] == "legacy-single-user"
|
||||
|
||||
@@ -6,6 +6,9 @@ every other user's preferences (a realistic ops transition: auth turned off
|
||||
on a deployment that previously ran multi-user). It must preserve the other
|
||||
users and round-trip the change into the same (first) slot _load_for_user
|
||||
reads from.
|
||||
|
||||
Foreground fallback keys are the exception: auth-disabled consent is stored
|
||||
at the flat root so it can never become consent for the first named owner.
|
||||
"""
|
||||
import json
|
||||
|
||||
@@ -51,3 +54,58 @@ def test_named_user_save_unaffected(tmp_path, monkeypatch):
|
||||
data = json.loads(f.read_text())
|
||||
assert data["_users"]["alice"] == {"theme": "light"}
|
||||
assert data["_users"]["bob"] == {"theme": "dark"}
|
||||
|
||||
|
||||
def test_auth_disabled_fallback_consent_does_not_mutate_first_named_user(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
f = tmp_path / "user_prefs.json"
|
||||
f.write_text(json.dumps({"_users": {
|
||||
"alice": {"theme": "light"},
|
||||
"bob": {"theme": "paper"},
|
||||
}}), encoding="utf-8")
|
||||
monkeypatch.setattr(pr, "PREFS_FILE", str(f))
|
||||
|
||||
current = pr._load_for_user(None)
|
||||
current["foreground_fallback_enabled"] = True
|
||||
current["foreground_model_fallbacks"] = [
|
||||
{"endpoint_id": "single-user", "model": "single-model"},
|
||||
]
|
||||
pr._save_for_user(None, current)
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
assert data["foreground_fallback_enabled"] is True
|
||||
assert data["foreground_model_fallbacks"][0]["endpoint_id"] == "single-user"
|
||||
assert data["_users"]["alice"] == {"theme": "light"}
|
||||
assert data["_users"]["bob"] == {"theme": "paper"}
|
||||
|
||||
|
||||
def test_auth_disabled_save_preserves_named_fallback_consent(tmp_path, monkeypatch):
|
||||
f = tmp_path / "user_prefs.json"
|
||||
alice_fallbacks = [{"endpoint_id": "alice", "model": "alice-model"}]
|
||||
f.write_text(json.dumps({"_users": {
|
||||
"alice": {
|
||||
"theme": "light",
|
||||
"foreground_fallback_enabled": True,
|
||||
"foreground_model_fallbacks": alice_fallbacks,
|
||||
},
|
||||
}}), encoding="utf-8")
|
||||
monkeypatch.setattr(pr, "PREFS_FILE", str(f))
|
||||
|
||||
current = pr._load_for_user(None)
|
||||
assert "foreground_fallback_enabled" not in current
|
||||
assert "foreground_model_fallbacks" not in current
|
||||
current["theme"] = "dark"
|
||||
current["foreground_fallback_enabled"] = False
|
||||
current["foreground_model_fallbacks"] = []
|
||||
pr._save_for_user(None, current)
|
||||
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
assert data["foreground_fallback_enabled"] is False
|
||||
assert data["foreground_model_fallbacks"] == []
|
||||
assert data["_users"]["alice"] == {
|
||||
"theme": "dark",
|
||||
"foreground_fallback_enabled": True,
|
||||
"foreground_model_fallbacks": alice_fallbacks,
|
||||
}
|
||||
|
||||
@@ -114,6 +114,12 @@ def _force_sql_owner_migration_failure(monkeypatch):
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
def update(self, *_args, **_kwargs):
|
||||
raise RuntimeError("forced owner migration failure")
|
||||
|
||||
@@ -125,6 +131,12 @@ def _force_sql_owner_migration_failure(monkeypatch):
|
||||
def query(self, _model):
|
||||
return FailingQuery()
|
||||
|
||||
def get_bind(self):
|
||||
return SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
|
||||
|
||||
def get(self, _model, _key, **_kwargs):
|
||||
return object()
|
||||
|
||||
def rollback(self):
|
||||
self.rolled_back = True
|
||||
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.endpoint_resolver import (
|
||||
endpoint_cost_tracked,
|
||||
resolve_endpoint,
|
||||
resolve_endpoint_by_id,
|
||||
resolve_fallback_entries,
|
||||
resolve_fallback_entries_with_descriptors,
|
||||
)
|
||||
|
||||
|
||||
class _FakeColumn:
|
||||
@@ -34,6 +42,9 @@ class _FakeQuery:
|
||||
def first(self):
|
||||
return self.rows[0] if self.rows else None
|
||||
|
||||
def all(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def __init__(self, rows):
|
||||
@@ -49,6 +60,7 @@ class _FakeDb:
|
||||
def _endpoint(ep_id, model, *, hidden=None):
|
||||
return SimpleNamespace(
|
||||
id=ep_id,
|
||||
name=f"Endpoint {ep_id}",
|
||||
base_url=f"https://{ep_id}.example/v1",
|
||||
api_key=f"key-{ep_id}",
|
||||
cached_models=json.dumps([model]),
|
||||
@@ -191,3 +203,140 @@ def test_hidden_configured_model_selects_first_enabled_chat_model(monkeypatch):
|
||||
assert url == "https://default.example/v1/chat/completions"
|
||||
assert model == "enabled-chat"
|
||||
assert headers == {"Authorization": "Bearer key-default"}
|
||||
|
||||
|
||||
def test_exact_fallback_drops_hidden_model_instead_of_substituting(monkeypatch):
|
||||
endpoint = SimpleNamespace(
|
||||
id="fallback",
|
||||
base_url="https://fallback.example/v1",
|
||||
api_key="key-fallback",
|
||||
cached_models=json.dumps(["chosen-hidden", "different-live"]),
|
||||
hidden_models=json.dumps(["chosen-hidden"]),
|
||||
is_enabled=True,
|
||||
)
|
||||
_install_resolver_fakes(monkeypatch, {}, [endpoint])
|
||||
|
||||
assert resolve_endpoint_by_id(
|
||||
"fallback",
|
||||
"chosen-hidden",
|
||||
require_exact_model=True,
|
||||
) is None
|
||||
assert resolve_endpoint_by_id("fallback", "chosen-hidden")[1] == "different-live"
|
||||
|
||||
|
||||
def test_exact_fallback_drops_known_missing_model(monkeypatch):
|
||||
_install_resolver_fakes(monkeypatch, {}, [_endpoint("fallback", "known-live")])
|
||||
|
||||
assert resolve_endpoint_by_id(
|
||||
"fallback",
|
||||
"unlisted-model",
|
||||
require_exact_model=True,
|
||||
) is None
|
||||
|
||||
|
||||
def test_fallback_entry_resolution_preserves_credential_distinct_endpoints(monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_resolve(ep_id, model, owner=None, *, require_exact_model=False):
|
||||
seen.append((ep_id, model, owner, require_exact_model))
|
||||
return (
|
||||
"https://provider.example/v1/chat/completions",
|
||||
model,
|
||||
{"Authorization": f"Bearer {ep_id}"},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_by_id", fake_resolve)
|
||||
entries = [
|
||||
{"endpoint_id": "key-one", "model": "same-model"},
|
||||
{"endpoint_id": "key-two", "model": "same-model"},
|
||||
]
|
||||
|
||||
assert resolve_fallback_entries(
|
||||
entries,
|
||||
owner="alice",
|
||||
require_exact_model=True,
|
||||
) == [
|
||||
("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-one"}),
|
||||
("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-two"}),
|
||||
]
|
||||
assert seen == [
|
||||
("key-one", "same-model", "alice", True),
|
||||
("key-two", "same-model", "alice", True),
|
||||
]
|
||||
|
||||
|
||||
def test_descriptor_resolution_preserves_safe_endpoint_identity(monkeypatch):
|
||||
_install_resolver_fakes(monkeypatch, {}, [_endpoint("backup", "backup-model")])
|
||||
|
||||
routes = resolve_fallback_entries_with_descriptors(
|
||||
[{"endpoint_id": "backup", "model": "backup-model"}],
|
||||
require_exact_model=True,
|
||||
)
|
||||
|
||||
assert routes == [(
|
||||
(
|
||||
"https://backup.example/v1/chat/completions",
|
||||
"backup-model",
|
||||
{"Authorization": "Bearer key-backup"},
|
||||
),
|
||||
{
|
||||
"endpoint_id": "backup",
|
||||
"endpoint_label": "Endpoint backup",
|
||||
"endpoint_cost_tracked": True,
|
||||
},
|
||||
)]
|
||||
|
||||
|
||||
def test_exact_id_descriptor_wins_when_routes_are_identical(monkeypatch):
|
||||
first = _endpoint("account-one", "same-model")
|
||||
second = _endpoint("account-two", "same-model")
|
||||
for endpoint in (first, second):
|
||||
endpoint.base_url = "https://provider.example/v1"
|
||||
endpoint.api_key = "shared-key"
|
||||
_install_resolver_fakes(monkeypatch, {}, [first, second])
|
||||
|
||||
import src.auth_helpers as auth_helpers
|
||||
|
||||
seen_owners = []
|
||||
|
||||
def scoped(query, model_cls, owner, *, include_shared=True):
|
||||
seen_owners.append(owner)
|
||||
return query
|
||||
|
||||
monkeypatch.setattr(auth_helpers, "owner_filter", scoped)
|
||||
resolver = getattr(endpoint_resolver, "resolve_route_descriptor_by_id", None)
|
||||
|
||||
assert resolver is not None
|
||||
assert resolver(
|
||||
"account-two",
|
||||
"https://provider.example/v1/chat/completions",
|
||||
"same-model",
|
||||
{"Authorization": "Bearer shared-key"},
|
||||
owner="alice",
|
||||
) == {
|
||||
"endpoint_id": "account-two",
|
||||
"endpoint_label": "Endpoint account-two",
|
||||
"endpoint_cost_tracked": True,
|
||||
}
|
||||
assert seen_owners == ["alice"]
|
||||
|
||||
|
||||
def test_endpoint_cost_tracking_is_non_secret_route_classification():
|
||||
assert endpoint_cost_tracked("http://localhost:11434/v1") is False
|
||||
assert endpoint_cost_tracked("http://model-service:8000/v1") is False
|
||||
assert endpoint_cost_tracked("http://192.168.1.20:8000/v1") is False
|
||||
assert endpoint_cost_tracked("https://chatgpt.com/backend-api/codex") is False
|
||||
assert endpoint_cost_tracked("https://api.example.com/v1") is True
|
||||
assert endpoint_cost_tracked("http://192.168.1.20:8000/v1", "api") is True
|
||||
assert endpoint_cost_tracked("https://api.example.com/v1", "local") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
("https://[2606:4700:4700::1111]/v1", True),
|
||||
("http://169.254.10.20:8000/v1", False),
|
||||
],
|
||||
)
|
||||
def test_endpoint_cost_tracking_classifies_public_ipv6_and_link_local_ipv4(url, expected):
|
||||
assert endpoint_cost_tracked(url) is expected
|
||||
|
||||
@@ -163,53 +163,3 @@ def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_chatgpt_subscription_fallback_auth_is_not_written_to_sessions_table(monkeypatch):
|
||||
"""Fallback endpoint selection must keep the resolved bearer request-local."""
|
||||
TestSessionLocal = _mem_db(monkeypatch)
|
||||
db = TestSessionLocal()
|
||||
try:
|
||||
db.add(ModelEndpoint(
|
||||
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
|
||||
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
|
||||
cached_models='["gpt-5.1-codex"]',
|
||||
))
|
||||
db.add(DbSession(
|
||||
id="sess1", name="chat", endpoint_url="https://old.example/v1",
|
||||
model="old-model", owner="alice", headers={},
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
monkeypatch.setattr(
|
||||
endpoint_resolver,
|
||||
"resolve_endpoint_runtime",
|
||||
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
|
||||
)
|
||||
|
||||
sess = types.SimpleNamespace(
|
||||
id="sess1", endpoint_url="https://old.example/v1", model="old-model",
|
||||
owner="alice", headers={},
|
||||
)
|
||||
result = chat_helpers.try_fallback_endpoint(sess, "sess1")
|
||||
|
||||
assert result == {
|
||||
"model": "gpt-5.1-codex",
|
||||
"endpoint_url": _CODEX_BASE + "/responses",
|
||||
"endpoint_name": "ChatGPT Subscription",
|
||||
}
|
||||
assert sess.headers["Authorization"] == "Bearer live-access-token"
|
||||
|
||||
db = TestSessionLocal()
|
||||
try:
|
||||
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
||||
assert row.model == "gpt-5.1-codex"
|
||||
assert row.endpoint_url == _CODEX_BASE + "/responses"
|
||||
stored = row.headers or {}
|
||||
assert not any(k.lower() == "authorization" for k in stored), (
|
||||
f"ChatGPT fallback bearer leaked into sessions table: {stored}"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Retired settings stay stored but cannot leak through generic interfaces."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import core.database as database
|
||||
import routes.auth_routes as auth_routes
|
||||
import src.settings as settings_mod
|
||||
from src.agent_tools.admin_tools import do_manage_settings
|
||||
|
||||
|
||||
LEGACY_VALUE = [
|
||||
{"endpoint_id": "private-endpoint-id", "model": "private-model-name"},
|
||||
]
|
||||
|
||||
|
||||
class _AuthManager:
|
||||
def get_username_for_token(self, token):
|
||||
return "admin" if token == "admin-session" else None
|
||||
|
||||
def is_admin(self, username):
|
||||
return username == "admin"
|
||||
|
||||
|
||||
class _Request(SimpleNamespace):
|
||||
def __init__(self, body=None, *, admin=False):
|
||||
super().__init__(
|
||||
cookies={
|
||||
auth_routes.SESSION_COOKIE: "admin-session"
|
||||
} if admin else {},
|
||||
_body=body,
|
||||
)
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
def _route(router, path, method):
|
||||
return next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == path and method in route.methods
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_settings_hide_and_preserve_retired_fallbacks(monkeypatch):
|
||||
store = {
|
||||
**settings_mod.DEFAULT_SETTINGS,
|
||||
"default_model_fallbacks": list(LEGACY_VALUE),
|
||||
"tts_enabled": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
|
||||
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
|
||||
|
||||
def save_settings(updated):
|
||||
store.clear()
|
||||
store.update(updated)
|
||||
|
||||
monkeypatch.setattr(auth_routes, "_save_settings", save_settings)
|
||||
router = auth_routes.setup_auth_routes(_AuthManager())
|
||||
get_settings = _route(router, "/api/auth/settings", "GET")
|
||||
set_settings = _route(router, "/api/auth/settings", "POST")
|
||||
|
||||
anonymous = await get_settings(_Request())
|
||||
admin = await get_settings(_Request(admin=True))
|
||||
|
||||
assert "default_model_fallbacks" not in anonymous
|
||||
assert "default_model_fallbacks" not in admin
|
||||
assert store["default_model_fallbacks"] == LEGACY_VALUE
|
||||
|
||||
response = await set_settings(_Request({
|
||||
"default_model_fallbacks": [],
|
||||
"tts_enabled": False,
|
||||
}, admin=True))
|
||||
|
||||
assert "default_model_fallbacks" not in response
|
||||
assert store["default_model_fallbacks"] == LEGACY_VALUE
|
||||
assert store["tts_enabled"] is False
|
||||
|
||||
|
||||
def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
|
||||
store = {
|
||||
**settings_mod.DEFAULT_SETTINGS,
|
||||
"default_model_fallbacks": list(LEGACY_VALUE),
|
||||
}
|
||||
save_calls = []
|
||||
|
||||
class _Db:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(database, "SessionLocal", lambda: _Db())
|
||||
monkeypatch.setattr(settings_mod, "load_settings", lambda: dict(store))
|
||||
|
||||
def save_settings(updated):
|
||||
save_calls.append(dict(updated))
|
||||
store.clear()
|
||||
store.update(updated)
|
||||
|
||||
monkeypatch.setattr(settings_mod, "save_settings", save_settings)
|
||||
|
||||
listed = asyncio.run(do_manage_settings(json.dumps({"action": "list"})))
|
||||
assert "default_model_fallbacks" not in listed["settings"]
|
||||
|
||||
for action in ("get", "set", "reset", "delete"):
|
||||
payload = {"action": action, "key": "default_model_fallbacks"}
|
||||
if action == "set":
|
||||
payload["value"] = []
|
||||
result = asyncio.run(do_manage_settings(json.dumps(payload)))
|
||||
assert result["exit_code"] == 1
|
||||
assert "Unknown setting" in result["error"]
|
||||
|
||||
assert save_calls == []
|
||||
assert store["default_model_fallbacks"] == LEGACY_VALUE
|
||||
@@ -0,0 +1,323 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MIGRATION = ROOT / "scripts" / "migrate_searxng_settings.py"
|
||||
COMPOSE_FILES = (
|
||||
ROOT / "docker-compose.yml",
|
||||
ROOT / "docker-compose.gpu-amd.yml",
|
||||
ROOT / "docker-compose.gpu-nvidia.yml",
|
||||
)
|
||||
|
||||
|
||||
def _run(path: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(MIGRATION), str(path)],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _load_migration_module():
|
||||
spec = importlib.util.spec_from_file_location("searxng_settings_migration", MIGRATION)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_retained_settings_gain_defaults_without_changing_custom_content(tmp_path):
|
||||
settings = tmp_path / "settings.yml"
|
||||
retained = (
|
||||
b"# retained deployment settings\n"
|
||||
b"server:\n"
|
||||
b' secret_key: "representative-retained-secret"\n'
|
||||
b"search:\n"
|
||||
b" safe_search: 1\n"
|
||||
b" formats:\n"
|
||||
b" - html\n"
|
||||
b" - json\n"
|
||||
b"ui:\n"
|
||||
b" static_use_hash: true\n"
|
||||
)
|
||||
settings.write_bytes(retained)
|
||||
settings.chmod(0o640)
|
||||
before = settings.stat()
|
||||
|
||||
first = _run(settings)
|
||||
|
||||
assert first.returncode == 0, first.stderr
|
||||
assert "representative-retained-secret" not in first.stdout + first.stderr
|
||||
assert settings.read_bytes() == (
|
||||
b"# retained deployment settings\n"
|
||||
b"use_default_settings: true\n"
|
||||
+ retained.removeprefix(b"# retained deployment settings\n")
|
||||
)
|
||||
after = settings.stat()
|
||||
assert stat.S_IMODE(after.st_mode) == 0o640
|
||||
assert (after.st_uid, after.st_gid) == (before.st_uid, before.st_gid)
|
||||
|
||||
migrated = settings.read_bytes()
|
||||
second = _run(settings)
|
||||
|
||||
assert second.returncode == 0, second.stderr
|
||||
assert settings.read_bytes() == migrated
|
||||
assert second.stdout == ""
|
||||
|
||||
|
||||
def test_fresh_generated_settings_are_left_byte_identical(tmp_path):
|
||||
settings = tmp_path / "settings.yml"
|
||||
generated = (ROOT / "config" / "searxng" / "settings.yml").read_bytes().replace(
|
||||
b"__SEARXNG_SECRET__", b"representative-generated-secret"
|
||||
)
|
||||
settings.write_bytes(generated)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert settings.read_bytes() == generated
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
(
|
||||
b"use_default_settings: false\n",
|
||||
b"use_default_settings:\n engines:\n keep_only:\n - brave\n",
|
||||
b"'use_default_settings': true\n",
|
||||
b'"use_default_settings": true\n',
|
||||
),
|
||||
)
|
||||
def test_explicit_top_level_setting_is_not_overridden(tmp_path, key):
|
||||
settings = tmp_path / "settings.yml"
|
||||
original = key + b"server:\n secret_key: retained\n"
|
||||
settings.write_bytes(original)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert settings.read_bytes() == original
|
||||
|
||||
|
||||
def test_key_is_inserted_inside_explicit_yaml_document(tmp_path):
|
||||
settings = tmp_path / "settings.yml"
|
||||
original = b"\xef\xbb\xbf# header\r\n---\r\nserver:\r\n secret_key: retained\r\n"
|
||||
settings.write_bytes(original)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert settings.read_bytes() == (
|
||||
b"\xef\xbb\xbf# header\r\n---\r\nuse_default_settings: true\r\n"
|
||||
b"server:\r\n secret_key: retained\r\n"
|
||||
)
|
||||
|
||||
|
||||
def test_indented_root_mapping_keeps_its_existing_indent(tmp_path):
|
||||
settings = tmp_path / "settings.yml"
|
||||
original = b" server:\n secret_key: retained\n search:\n safe_search: 1\n"
|
||||
settings.write_bytes(original)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert settings.read_bytes() == b" use_default_settings: true\n" + original
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"property_line",
|
||||
(b"!!map\n", b"&settings\n", b"--- !!map\n"),
|
||||
)
|
||||
def test_block_mapping_properties_stay_attached_to_the_root(tmp_path, property_line):
|
||||
settings = tmp_path / "settings.yml"
|
||||
mapping = b"server:\n secret_key: retained\nsearch:\n safe_search: 1\n"
|
||||
original = property_line + mapping
|
||||
settings.write_bytes(original)
|
||||
original_data = yaml.safe_load(original)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
expected = property_line + b"use_default_settings: true\n" + mapping
|
||||
assert settings.read_bytes() == expected
|
||||
migrated_data = yaml.safe_load(settings.read_bytes())
|
||||
assert migrated_data.pop("use_default_settings") is True
|
||||
assert migrated_data == original_data
|
||||
|
||||
|
||||
def test_flow_mapping_gains_default_inheritance_without_reformatting(tmp_path):
|
||||
settings = tmp_path / "settings.yml"
|
||||
original = b"{server: {secret_key: retained}, search: {safe_search: 1}}\n"
|
||||
settings.write_bytes(original)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert settings.read_bytes() == (
|
||||
b"{use_default_settings: true, " + original.removeprefix(b"{")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("original", "expected"),
|
||||
(
|
||||
(b"{}\n", b"{use_default_settings: true}\n"),
|
||||
(
|
||||
b"{server: {use_default_settings: false, secret_key: retained}}\n",
|
||||
b"{use_default_settings: true, "
|
||||
b"server: {use_default_settings: false, secret_key: retained}}\n",
|
||||
),
|
||||
(
|
||||
b"!!map {server: {secret_key: retained}}\n",
|
||||
b"!!map {use_default_settings: true, server: {secret_key: retained}}\n",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_other_flow_mapping_shapes_gain_only_the_root_key(tmp_path, original, expected):
|
||||
settings = tmp_path / "settings.yml"
|
||||
settings.write_bytes(original)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert settings.read_bytes() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"original",
|
||||
(
|
||||
b"{use_default_settings: true, server: {secret_key: retained}}\n",
|
||||
b'{"use_default_settings": {engines: {keep_only: [brave]}}}\n',
|
||||
b"{use_default_settings: true, server: {secret_key: abc#def}}\n",
|
||||
b"{use_default_settings: true, server: {secret_key: 'ab''cd'}}\n",
|
||||
),
|
||||
)
|
||||
def test_flow_mapping_with_existing_setting_is_left_untouched(tmp_path, original):
|
||||
settings = tmp_path / "settings.yml"
|
||||
settings.write_bytes(original)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert settings.read_bytes() == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"original",
|
||||
(
|
||||
b"%YAML 1.1\n---\nuse_default_settings: true\n"
|
||||
b"server:\n secret_key: retained\n",
|
||||
b"use_default_settings: true\nserver:\n secret_key: retained\n...\n",
|
||||
),
|
||||
)
|
||||
def test_valid_document_metadata_with_existing_key_is_left_untouched(
|
||||
tmp_path, original
|
||||
):
|
||||
settings = tmp_path / "settings.yml"
|
||||
settings.write_bytes(original)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert settings.read_bytes() == original
|
||||
|
||||
|
||||
def test_invalid_utf8_is_not_replaced(tmp_path):
|
||||
settings = tmp_path / "settings.yml"
|
||||
original = b"server:\n secret_key: \xff\n"
|
||||
settings.write_bytes(original)
|
||||
before = os.stat(settings)
|
||||
|
||||
result = _run(settings)
|
||||
|
||||
after = os.stat(settings)
|
||||
assert result.returncode == 1
|
||||
assert settings.read_bytes() == original
|
||||
assert after.st_ino == before.st_ino
|
||||
|
||||
|
||||
def test_temporary_file_is_chmodded_before_it_is_chowned(tmp_path, monkeypatch):
|
||||
# The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/
|
||||
# DAC_OVERRIDE and carries no FOWNER, and searxng's entrypoint chowns
|
||||
# /etc/searxng to searxng:searxng, so every retained settings file is owned
|
||||
# by that user. Chowning the temporary file first therefore makes the chmod
|
||||
# that follows fail with EPERM, and `set -eu` in the Compose entrypoint
|
||||
# turns that into a container that never starts. The guard below refuses
|
||||
# the chmod once the chown has landed, the way the kernel does.
|
||||
migration = _load_migration_module()
|
||||
settings = tmp_path / "settings.yml"
|
||||
settings.write_bytes(b"server:\n secret_key: retained\n")
|
||||
settings.chmod(0o640)
|
||||
calls = []
|
||||
real_fchmod = migration.os.fchmod
|
||||
real_fchown = migration.os.fchown
|
||||
|
||||
def guarded_fchmod(fd, mode):
|
||||
if "fchown" in calls:
|
||||
raise PermissionError(1, "Operation not permitted")
|
||||
calls.append("fchmod")
|
||||
return real_fchmod(fd, mode)
|
||||
|
||||
def recording_fchown(fd, uid, gid):
|
||||
calls.append("fchown")
|
||||
return real_fchown(fd, uid, gid)
|
||||
|
||||
monkeypatch.setattr(migration.os, "fchmod", guarded_fchmod)
|
||||
monkeypatch.setattr(migration.os, "fchown", recording_fchown)
|
||||
|
||||
assert migration.migrate_settings(settings) is True
|
||||
|
||||
assert calls == ["fchmod", "fchown"]
|
||||
assert stat.S_IMODE(settings.stat().st_mode) == 0o640
|
||||
assert settings.read_bytes() == (
|
||||
b"use_default_settings: true\nserver:\n secret_key: retained\n"
|
||||
)
|
||||
|
||||
|
||||
def test_replace_failure_preserves_original_and_removes_temporary_file(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
migration = _load_migration_module()
|
||||
settings = tmp_path / "settings.yml"
|
||||
original = b"server:\n secret_key: retained\n"
|
||||
settings.write_bytes(original)
|
||||
before = settings.stat()
|
||||
|
||||
def fail_replace(_source, _destination):
|
||||
raise OSError("injected replace failure")
|
||||
|
||||
monkeypatch.setattr(migration.os, "replace", fail_replace)
|
||||
|
||||
with pytest.raises(OSError, match="injected replace failure"):
|
||||
migration.migrate_settings(settings)
|
||||
|
||||
after = settings.stat()
|
||||
assert settings.read_bytes() == original
|
||||
assert after.st_ino == before.st_ino
|
||||
assert list(tmp_path.iterdir()) == [settings]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("compose_file", COMPOSE_FILES, ids=lambda path: path.name)
|
||||
def test_compose_runs_migration_for_all_variants(compose_file):
|
||||
text = compose_file.read_text(encoding="utf-8")
|
||||
|
||||
# The `|| true` is load-bearing: the entrypoint runs under `set -eu`, so
|
||||
# without it a settings file the migration cannot parse or rewrite stops
|
||||
# searxng from starting at all instead of merely going unmigrated.
|
||||
assert (
|
||||
"/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py "
|
||||
"/etc/searxng/settings.yml || true" in text
|
||||
)
|
||||
assert (
|
||||
"./scripts/migrate_searxng_settings.py:"
|
||||
"/tmp/migrate-searxng-settings.py:ro,z" in text
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Regression for issue #5210 — SKILL.md frontmatter scalars must round-trip.
|
||||
|
||||
``_emit_scalar`` quotes a scalar with ``json.dumps`` whenever it contains
|
||||
punctuation that would change how the line reads back. ``_parse_scalar`` used
|
||||
to undo that with a bare ``raw[1:-1]``: it stripped the quotes but never
|
||||
decoded the escapes. So ``"Pr\\u00fcfung"`` was read back as the literal text
|
||||
``Pr\\u00fcfung``, and the next save escaped *that* backslash again.
|
||||
|
||||
The damage compounds — each save doubles the backslash run — so a German or
|
||||
Japanese skill description degrades into backslash noise after a handful of
|
||||
edits, and the same happens to a plain-ASCII description that merely contains
|
||||
a quote character. The escapes are also shown verbatim in the skills list and
|
||||
the ``/skills`` catalog.
|
||||
|
||||
The fix is symmetric: emit with ``ensure_ascii=False`` (SKILL.md is UTF-8 at
|
||||
both ends) and parse double-quoted scalars with ``json.loads``.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from services.memory.skill_format import (
|
||||
Skill,
|
||||
_emit_scalar,
|
||||
_parse_scalar,
|
||||
parse_frontmatter,
|
||||
)
|
||||
from services.memory.skills import SkillsManager
|
||||
|
||||
# Umlauts plus a comma — the comma is what forces the quoted form, which is the
|
||||
# only path that was corrupted. Reported verbatim in issue #5210.
|
||||
GERMAN = "Einstiegs- und Pr\u00fcfungslinie f\u00fcr AGB, Datenschutz"
|
||||
JAPANESE = "\u30b9\u30ad\u30eb: \u30c6\u30b9\u30c8\u7528\u306e\u8aac\u660e"
|
||||
QUOTED_ASCII = 'Use the "grep" tool, then summarise'
|
||||
|
||||
|
||||
def _cycle(skill: Skill, times: int = 1) -> Skill:
|
||||
"""Save to markdown and read it straight back, `times` times over."""
|
||||
for _ in range(times):
|
||||
skill = Skill.from_markdown(skill.to_markdown())
|
||||
return skill
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The reported corruption
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"description",
|
||||
[GERMAN, JAPANESE, QUOTED_ASCII],
|
||||
ids=["german", "japanese", "quoted-ascii"],
|
||||
)
|
||||
def test_description_survives_repeated_saves(description):
|
||||
"""Five load/save cycles must leave the text byte-identical.
|
||||
|
||||
One cycle is enough to corrupt it; five is where the doubling became
|
||||
obvious in the field.
|
||||
"""
|
||||
result = _cycle(Skill(name="demo", description=description), times=5)
|
||||
assert result.description == description
|
||||
|
||||
|
||||
def test_corruption_does_not_compound_across_saves():
|
||||
"""Pin the *growth*, not just the mismatch.
|
||||
|
||||
The original defect was not a one-off mangling — the escaped form was
|
||||
re-escaped on every save, so the value grew without bound. A regression
|
||||
that reintroduced single-level mangling would still be caught by the test
|
||||
above; this one catches the runaway specifically.
|
||||
"""
|
||||
skill = Skill(name="demo", description=GERMAN)
|
||||
lengths = []
|
||||
for _ in range(5):
|
||||
skill = _cycle(skill)
|
||||
lengths.append(len(skill.description))
|
||||
assert len(set(lengths)) == 1, f"description length drifted across saves: {lengths}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What actually lands on disk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_non_ascii_is_written_as_utf8_not_ascii_escapes():
|
||||
"""SKILL.md is opened as UTF-8 at both ends, so \\uXXXX buys nothing and
|
||||
only makes the file unreadable to a human editing it."""
|
||||
markdown = Skill(name="demo", description=GERMAN).to_markdown()
|
||||
line = next(l for l in markdown.splitlines() if l.startswith("description:"))
|
||||
assert "Pr\u00fcfungslinie" in line
|
||||
assert "\\u00fc" not in line
|
||||
|
||||
|
||||
def test_quoted_scalar_is_valid_json():
|
||||
"""The emitted form is what the parser now feeds to json.loads, so the two
|
||||
halves cannot drift apart without this failing."""
|
||||
emitted = _emit_scalar(QUOTED_ASCII)
|
||||
assert json.loads(emitted) == QUOTED_ASCII
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Existing files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_legacy_ascii_escaped_file_is_read_correctly():
|
||||
"""Files already written by the old emitter hold real JSON escapes, so the
|
||||
new parser recovers the intended text instead of the escape source."""
|
||||
markdown = '---\nname: demo\ndescription: "Pr\\u00fcfung, x"\n---\n\n'
|
||||
assert Skill.from_markdown(markdown).description == "Pr\u00fcfung, x"
|
||||
|
||||
|
||||
def test_already_corrupted_file_heals_one_level_per_load():
|
||||
"""A file that took one round of damage carries a doubled backslash. That
|
||||
is still valid JSON, so reading it yields the single-backslash form and the
|
||||
value stops degrading."""
|
||||
markdown = '---\nname: demo\ndescription: "Pr\\\\u00fcfung, x"\n---\n\n'
|
||||
once = Skill.from_markdown(markdown)
|
||||
assert once.description == "Pr\\u00fcfung, x"
|
||||
# And it is now stable rather than growing on every subsequent save.
|
||||
assert _cycle(once, times=3).description == "Pr\\u00fcfung, x"
|
||||
|
||||
|
||||
def test_non_json_escape_falls_back_to_literal_reading():
|
||||
"""A hand-written frontmatter value can hold escapes JSON rejects (a bare
|
||||
Windows path is the common one). Those must keep their previous literal
|
||||
reading rather than raising."""
|
||||
assert _parse_scalar('"C:\\Users\\demo"') == "C:\\Users\\demo"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unchanged behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_plain_scalars_are_still_emitted_bare():
|
||||
"""Only values needing quotes get them — the common case must not suddenly
|
||||
start quoting, which would churn every SKILL.md on disk."""
|
||||
assert _emit_scalar("open-pr-from-branch") == "open-pr-from-branch"
|
||||
assert _emit_scalar("1.0.0") == "1.0.0"
|
||||
assert _emit_scalar(True) == "true"
|
||||
assert _emit_scalar(None) == "null"
|
||||
assert _emit_scalar(0.8) == "0.8"
|
||||
|
||||
|
||||
def test_single_quoted_scalar_keeps_literal_reading():
|
||||
"""Only double-quoted scalars are JSON. Single-quoted ones are read the way
|
||||
they always were."""
|
||||
assert _parse_scalar("'plain, text'") == "plain, text"
|
||||
|
||||
|
||||
def test_lists_round_trip_with_non_ascii_entries():
|
||||
skill = Skill(name="demo", tags=["b\u00fcro", "recht, steuern"])
|
||||
assert _cycle(skill, times=3).tags == ["b\u00fcro", "recht, steuern"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Line-break characters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sep",
|
||||
["\u2028", "\u2029", "\x85", "\r", "\v", "\f", "\x1c"],
|
||||
ids=["ls", "ps", "nel", "cr", "vt", "ff", "fs"],
|
||||
)
|
||||
def test_line_break_characters_do_not_split_the_frontmatter(sep):
|
||||
"""parse_frontmatter() reads one scalar per line via str.splitlines(),
|
||||
which breaks on far more than \\n. Any of these landing unescaped in the
|
||||
file would silently truncate the value and shift the remainder into a
|
||||
bogus key.
|
||||
|
||||
json.dumps covers the C0 ones, but with ensure_ascii=False it passes NEL,
|
||||
LINE SEPARATOR and PARAGRAPH SEPARATOR through as themselves — so those
|
||||
three are re-escaped explicitly.
|
||||
"""
|
||||
description = f"before{sep}after, x"
|
||||
markdown = Skill(name="demo", description=description).to_markdown()
|
||||
|
||||
frontmatter_text = markdown.split("---")[1]
|
||||
assert len(frontmatter_text.strip().splitlines()) == len(
|
||||
[l for l in frontmatter_text.strip().split("\n") if l.strip()]
|
||||
), "a scalar leaked a line break into the frontmatter"
|
||||
|
||||
fm, _body = parse_frontmatter(markdown)
|
||||
assert fm["description"] == description
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End to end, through real files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_description_survives_real_save_load_cycles_on_disk(tmp_path):
|
||||
"""The unit tests above go straight through to_markdown/from_markdown.
|
||||
This drives the same path the app does — SkillsManager writing UTF-8 files
|
||||
with atomic_write_text and reading them back — because the encoding used at
|
||||
either end is part of the fix.
|
||||
"""
|
||||
manager = SkillsManager(str(tmp_path))
|
||||
manager.add_skill(name="agb-pruefung", description=GERMAN, category="general")
|
||||
|
||||
for _ in range(4):
|
||||
assert manager.update_skill("agb-pruefung", {"status": "published"})
|
||||
|
||||
stored = [s for s in manager.load_all() if s["name"] == "agb-pruefung"]
|
||||
assert len(stored) == 1
|
||||
assert stored[0]["description"] == GERMAN
|
||||
|
||||
on_disk = (tmp_path / "skills" / "general" / "agb-pruefung" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert GERMAN in on_disk
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Skill URL importer — GitHub path parsing."""
|
||||
import ipaddress
|
||||
|
||||
import pytest
|
||||
|
||||
from services.memory.skill_importer import (
|
||||
@@ -11,6 +13,13 @@ from services.memory.skill_importer import (
|
||||
)
|
||||
|
||||
|
||||
def _allow_fetch(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer._resolve_and_check_url",
|
||||
lambda url: [ipaddress.ip_address("93.184.216.34")],
|
||||
)
|
||||
|
||||
|
||||
def test_parse_github_blob_skill_md():
|
||||
src = parse_skill_source(
|
||||
"https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md"
|
||||
@@ -69,10 +78,7 @@ def test_fetch_bytes_rejects_cross_host_redirect(monkeypatch):
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer.check_outbound_url",
|
||||
lambda url, **kwargs: (True, ""),
|
||||
)
|
||||
_allow_fetch(monkeypatch)
|
||||
with pytest.raises(SkillImportError, match="redirect target"):
|
||||
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
|
||||
|
||||
@@ -89,10 +95,7 @@ def test_list_github_dir_accepts_api_github_response(monkeypatch):
|
||||
"services.memory.skill_importer._fetch_text",
|
||||
lambda url: "# skill\n",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer.check_outbound_url",
|
||||
lambda url, **kwargs: (True, ""),
|
||||
)
|
||||
_allow_fetch(monkeypatch)
|
||||
|
||||
class _Resp:
|
||||
url = "https://api.github.com/repos/o/r/contents?ref=main"
|
||||
@@ -144,10 +147,7 @@ def _mock_httpx_client(monkeypatch, response):
|
||||
return response
|
||||
|
||||
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
|
||||
monkeypatch.setattr(
|
||||
"services.memory.skill_importer.check_outbound_url",
|
||||
lambda url, **kwargs: (True, ""),
|
||||
)
|
||||
_allow_fetch(monkeypatch)
|
||||
|
||||
|
||||
def test_list_github_dir_surfaces_rate_limit(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Deterministic regressions for skill-import DNS validation and pinning."""
|
||||
|
||||
import gzip
|
||||
import ipaddress
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
|
||||
from services.memory import skill_importer
|
||||
|
||||
|
||||
PUBLIC_A = ipaddress.ip_address("93.184.216.34")
|
||||
PUBLIC_B = ipaddress.ip_address("1.1.1.1")
|
||||
|
||||
|
||||
def test_validation_snapshot_is_the_only_connect_destination(monkeypatch):
|
||||
answers = iter([
|
||||
[str(PUBLIC_A)],
|
||||
["127.0.0.1"],
|
||||
])
|
||||
resolver_calls = []
|
||||
|
||||
def _flipping_resolver(host):
|
||||
resolver_calls.append(host)
|
||||
return next(answers)
|
||||
|
||||
monkeypatch.setattr(skill_importer, "_default_resolver", _flipping_resolver)
|
||||
pinned_ips = skill_importer._check_fetch_url("https://rebind.example/skill")
|
||||
|
||||
connected = []
|
||||
|
||||
class _RecordingBackend:
|
||||
def connect_tcp(self, host, port, timeout, local_address, socket_options):
|
||||
connected.append((host, port))
|
||||
return object()
|
||||
|
||||
backend = skill_importer._PinnedBackend(pinned_ips)
|
||||
backend._real = _RecordingBackend()
|
||||
backend.connect_tcp("rebind.example", 443, timeout=1.0)
|
||||
|
||||
assert resolver_calls == ["rebind.example"]
|
||||
assert pinned_ips == [PUBLIC_A]
|
||||
assert connected == [(str(PUBLIC_A), 443)]
|
||||
|
||||
|
||||
def test_pinned_backend_falls_back_only_within_validated_snapshot():
|
||||
attempts = []
|
||||
|
||||
class _FallbackBackend:
|
||||
def connect_tcp(self, host, port, timeout, local_address, socket_options):
|
||||
attempts.append((host, timeout))
|
||||
if host == str(PUBLIC_A):
|
||||
raise httpcore.ConnectError("first address unavailable")
|
||||
return "connected"
|
||||
|
||||
backend = skill_importer._PinnedBackend([PUBLIC_A, PUBLIC_B])
|
||||
backend._real = _FallbackBackend()
|
||||
|
||||
assert backend.connect_tcp("rebind.example", 443, timeout=1.0) == "connected"
|
||||
assert [host for host, _ in attempts] == [str(PUBLIC_A), str(PUBLIC_B)]
|
||||
assert all(timeout is not None and 0 <= timeout <= 1.0 for _, timeout in attempts)
|
||||
|
||||
|
||||
def test_transport_preserves_request_authority_and_response_url():
|
||||
recorded = []
|
||||
|
||||
class _CoreResponse:
|
||||
status = 200
|
||||
headers = [(b"content-type", b"text/plain")]
|
||||
stream = [b"ok"]
|
||||
extensions = {}
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
class _RecordingPool:
|
||||
def handle_request(self, request):
|
||||
recorded.append(request)
|
||||
return _CoreResponse()
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
transport = skill_importer._PinnedTransport([PUBLIC_A])
|
||||
transport._pool.close()
|
||||
transport._pool = _RecordingPool()
|
||||
|
||||
url = "https://github.com:444/octocat/repo?q=1"
|
||||
with httpx.Client(transport=transport) as client:
|
||||
response = client.get(url)
|
||||
|
||||
core_request = recorded[0]
|
||||
assert core_request.url.host == b"github.com"
|
||||
assert core_request.url.port == 444
|
||||
assert core_request.url.target == b"/octocat/repo?q=1"
|
||||
assert (b"host", b"github.com:444") in [
|
||||
(name.lower(), value) for name, value in core_request.headers
|
||||
]
|
||||
assert str(response.url) == url
|
||||
|
||||
|
||||
def test_get_checked_uses_fresh_transport_per_redirect_hop(monkeypatch):
|
||||
first = "https://github.com/owner/repo"
|
||||
second = "https://raw.githubusercontent.com/owner/repo/main/SKILL.md"
|
||||
snapshots = {
|
||||
first: [PUBLIC_A],
|
||||
second: [PUBLIC_B],
|
||||
}
|
||||
clients = []
|
||||
requested = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
skill_importer,
|
||||
"_resolve_and_check_url",
|
||||
lambda url: snapshots[url],
|
||||
)
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *, transport, follow_redirects, timeout):
|
||||
assert follow_redirects is False
|
||||
clients.append((transport._pinned_ips, timeout))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, headers=None):
|
||||
requested.append((url, headers))
|
||||
request = httpx.Request("GET", url)
|
||||
if url == first:
|
||||
return httpx.Response(
|
||||
302,
|
||||
headers={"location": second},
|
||||
request=request,
|
||||
)
|
||||
return httpx.Response(200, content=b"ok", request=request)
|
||||
|
||||
monkeypatch.setattr(skill_importer.httpx, "Client", _Client)
|
||||
|
||||
response = skill_importer._get_checked(first, headers={"Accept": "text/plain"})
|
||||
|
||||
assert [ips for ips, _ in clients] == [[PUBLIC_A], [PUBLIC_B]]
|
||||
assert requested == [
|
||||
(first, {"Accept": "text/plain"}),
|
||||
(second, {"Accept": "text/plain"}),
|
||||
]
|
||||
assert str(response.url) == second
|
||||
|
||||
|
||||
def test_dns_rebinding_pinned_transport_dials_pinned_ip():
|
||||
"""The real pool must dial the pinned IP and keep the logical request intact.
|
||||
|
||||
Everything above this test replaces the pool or the backend, so this is the
|
||||
only case that exercises ``httpcore.ConnectionPool`` end to end: the socket
|
||||
goes to the pinned address while URL, ``Host``, and the decoded body stay on
|
||||
the original hostname.
|
||||
"""
|
||||
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server_socket.bind(("127.0.0.1", 0))
|
||||
server_socket.listen(1)
|
||||
port = server_socket.getsockname()[1]
|
||||
|
||||
captured_request = b""
|
||||
client_address = None
|
||||
server_error = None
|
||||
|
||||
def handle_client():
|
||||
nonlocal captured_request, client_address, server_error
|
||||
try:
|
||||
conn, client_address = server_socket.accept()
|
||||
with conn:
|
||||
conn.settimeout(2.0)
|
||||
while b"\r\n\r\n" not in captured_request:
|
||||
if len(captured_request) >= 16_384:
|
||||
raise AssertionError("request headers exceeded 16 KiB")
|
||||
chunk = conn.recv(min(4096, 16_384 - len(captured_request)))
|
||||
if not chunk:
|
||||
break
|
||||
captured_request += chunk
|
||||
if b"\r\n\r\n" not in captured_request:
|
||||
raise AssertionError(
|
||||
"connection closed before request headers completed"
|
||||
)
|
||||
body = gzip.compress(b"successfully decoded gzip body")
|
||||
conn.sendall(
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Encoding: gzip\r\n"
|
||||
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
||||
b"\r\n" + body
|
||||
)
|
||||
except Exception as exc: # surfaced by the assertion below
|
||||
server_error = exc
|
||||
|
||||
server_thread = threading.Thread(target=handle_client, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Any hostname works: only the pinned snapshot decides where the socket goes.
|
||||
url = f"http://rebind.example:{port}/secret-metadata"
|
||||
try:
|
||||
with httpx.Client(
|
||||
transport=skill_importer._PinnedTransport([ipaddress.ip_address("127.0.0.1")])
|
||||
) as client:
|
||||
response = client.get(url)
|
||||
server_thread.join(timeout=5.0)
|
||||
finally:
|
||||
server_socket.close()
|
||||
|
||||
assert server_error is None, server_error
|
||||
assert not server_thread.is_alive()
|
||||
assert client_address is not None and client_address[0] == "127.0.0.1"
|
||||
assert f"host: rebind.example:{port}".encode() in captured_request.lower()
|
||||
assert str(response.url) == url
|
||||
assert response.text == "successfully decoded gzip body"
|
||||
@@ -0,0 +1,155 @@
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.memory.skill_importer import (
|
||||
ResolvedSource,
|
||||
SkillImportError,
|
||||
check_outbound_url,
|
||||
parse_skill_source,
|
||||
)
|
||||
|
||||
## 1. Tests for Hostname Dispatch & Substring Spoofing
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://skills.sh.attacker.com/owner/repo",
|
||||
"https://evilskills.sh/owner/repo",
|
||||
"https://notskills.sh/owner/repo",
|
||||
"https://api.skills.sh/owner/repo",
|
||||
"https://1.1.1.1/skills.sh/owner/repo",
|
||||
"http://localhost/skills.sh/owner/repo",
|
||||
],
|
||||
)
|
||||
def test_parse_skill_source_rejects_unsupported_host_before_fetch(url):
|
||||
"""Unsupported authorities must never reach the network unwrap path."""
|
||||
with patch("services.memory.skill_importer._get_checked") as mock_get:
|
||||
with pytest.raises(SkillImportError):
|
||||
parse_skill_source(url)
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["https://skills.sh/my-skill", "https://www.skills.sh/my-skill"])
|
||||
def test_parse_skill_source_unwraps_skills_sh_redirect_to_github(entry):
|
||||
"""Both skills.sh spellings unwrap when the fetch lands on a GitHub host."""
|
||||
with patch("services.memory.skill_importer._get_checked") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.url = "https://github.com/test-owner/test-repo"
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
source = parse_skill_source(entry)
|
||||
assert source.owner == "test-owner"
|
||||
assert source.repo == "test-repo"
|
||||
|
||||
|
||||
def test_parse_skill_source_rejects_skills_sh_page_that_never_reaches_github():
|
||||
"""A skills.sh page that does not redirect to GitHub must fail loudly.
|
||||
|
||||
The live site serves the skill page from ``www.skills.sh`` and only ever
|
||||
links the repository root, never the skill's subdirectory. Scraping a
|
||||
``github.com`` link out of the body therefore resolves every skill in a
|
||||
repo to the same bundle, so the importer must refuse rather than guess.
|
||||
"""
|
||||
body = '<html><body><a href="https://github.com/test-owner/test-repo">Repository</a></body></html>'
|
||||
with patch("services.memory.skill_importer._get_checked") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.url = "https://www.skills.sh/anthropics/skills/pdf"
|
||||
mock_response.text = body
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
with pytest.raises(
|
||||
SkillImportError, match="did not redirect to GitHub"
|
||||
) as exc_info:
|
||||
parse_skill_source("https://skills.sh/anthropics/skills/pdf")
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "exact skill folder or SKILL.md file" in message
|
||||
assert "repository-root link alone is not sufficient" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected"),
|
||||
[
|
||||
("ftp://github.com/o/r", "unsupported URL scheme: ftp"),
|
||||
("file:///etc/passwd", "unsupported URL scheme: file"),
|
||||
("gopher://github.com/o/r", "unsupported URL scheme: gopher"),
|
||||
("javascript:alert(1)", "Only GitHub or skills.sh URLs are supported"),
|
||||
("mailto:x@y.z", "Only GitHub or skills.sh URLs are supported"),
|
||||
("data:text/html,<b>x</b>", "Only GitHub or skills.sh URLs are supported"),
|
||||
("https://evil.example/o/r", "Only GitHub or skills.sh URLs are supported"),
|
||||
],
|
||||
)
|
||||
def test_parse_skill_source_reports_the_real_reason_for_rejection(url, expected):
|
||||
"""A supplied-but-unusable URL must not be reported as a missing URL."""
|
||||
with patch("services.memory.skill_importer._get_checked") as mock_get:
|
||||
with pytest.raises(SkillImportError, match=re.escape(expected)):
|
||||
parse_skill_source(url)
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "owner", "repo"),
|
||||
[
|
||||
("github.com/octocat/Hello-World", "octocat", "Hello-World"),
|
||||
("HTTPS://github.com/octocat/Hello-World", "octocat", "Hello-World"),
|
||||
("github.com:443/octocat/Hello-World", "octocat", "Hello-World"),
|
||||
("github.com/octocat/Hello-World?q=a://b", "octocat", "Hello-World"),
|
||||
],
|
||||
)
|
||||
def test_parse_skill_source_accepts_schemeless_and_uppercase_github(url, owner, repo):
|
||||
"""A schemeless host, an uppercase scheme, and a ``://`` in the query all parse."""
|
||||
source = parse_skill_source(url)
|
||||
assert (source.owner, source.repo) == (owner, repo)
|
||||
|
||||
|
||||
def test_parse_skill_source_valid_github():
|
||||
"""Ensure standard GitHub URLs parse into the correct ResolvedSource fields."""
|
||||
source = parse_skill_source("https://github.com/octocat/Hello-World/tree/main/docs")
|
||||
assert isinstance(source, ResolvedSource)
|
||||
assert source.owner == "octocat"
|
||||
assert source.repo == "Hello-World"
|
||||
assert source.ref == "main"
|
||||
assert source.path == "docs"
|
||||
|
||||
|
||||
## 2. Tests for SSRF Guard & CGNAT
|
||||
|
||||
def test_check_outbound_url_blocks_cgnat():
|
||||
"""Ensure Carrier-Grade NAT (RFC 6598) block 100.64.0.0/10 is blocked."""
|
||||
def mock_resolver(host):
|
||||
return ["100.64.5.10"]
|
||||
|
||||
ok, reason = check_outbound_url("http://example.com", block_private=True, resolver=mock_resolver)
|
||||
assert not ok
|
||||
assert "private/shared/loopback" in reason # Updated to match your codebase's error string
|
||||
|
||||
def test_check_outbound_url_blocks_loopback():
|
||||
"""Ensure loopback IPs (127.0.0.1) are blocked by default."""
|
||||
def mock_resolver(host):
|
||||
return ["127.0.0.1"]
|
||||
|
||||
ok, reason = check_outbound_url("http://localhost", block_private=True, resolver=mock_resolver)
|
||||
assert not ok
|
||||
|
||||
|
||||
def test_check_outbound_url_blocks_metadata():
|
||||
"""Ensure cloud metadata endpoints (169.254.169.254) are blocked."""
|
||||
def mock_resolver(host):
|
||||
return ["169.254.169.254"]
|
||||
|
||||
ok, reason = check_outbound_url("http://metadata.google.internal", block_private=True, resolver=mock_resolver)
|
||||
assert not ok
|
||||
|
||||
|
||||
def test_check_outbound_url_allows_public_ip():
|
||||
"""Ensure public routable IPs pass successfully."""
|
||||
def mock_resolver(host):
|
||||
return ["93.184.216.34"]
|
||||
|
||||
ok, reason = check_outbound_url("http://example.com", block_private=True, resolver=mock_resolver)
|
||||
assert ok
|
||||
assert reason == "ok"
|
||||
@@ -6,10 +6,12 @@ path in ``services/search/content.py:_get_public_url``. Previously it used
|
||||
``httpx``'s ``follow_redirects=True`` with the lenient guard on the *initial*
|
||||
URL only, so a ``3xx`` to an internal/metadata address was still connected to.
|
||||
|
||||
These tests are hermetic: every host is an IP literal, so ``check_outbound_url``
|
||||
resolves them locally (``getaddrinfo`` on a numeric address does no DNS) and no
|
||||
network access is required. The HTTP layer is faked so no real request is made.
|
||||
These tests are hermetic: public and internal guard cases use IP literals, while
|
||||
the exact ``skills.sh`` case injects its validated address snapshot. The HTTP
|
||||
layer is faked, so no real DNS lookup or request is made.
|
||||
"""
|
||||
import ipaddress
|
||||
|
||||
import pytest
|
||||
|
||||
from services.memory import skill_importer
|
||||
@@ -108,10 +110,20 @@ def test_fetch_bytes_blocks_redirect_to_internal(monkeypatch, internal):
|
||||
|
||||
def test_skills_sh_entry_blocks_redirect_to_metadata(monkeypatch):
|
||||
# The skills.sh unwrap path (user-supplied host) must also revalidate hops.
|
||||
raw = "http://1.1.1.1/skills.sh" # contains "skills.sh", not "github.com"
|
||||
raw = "https://skills.sh/example/skill"
|
||||
checked = []
|
||||
|
||||
def _check_hop(url):
|
||||
checked.append(url)
|
||||
if url == raw:
|
||||
return [ipaddress.ip_address("1.1.1.1")]
|
||||
raise SkillImportError("outbound URL blocked: private target")
|
||||
|
||||
monkeypatch.setattr(skill_importer, "_resolve_and_check_url", _check_hop)
|
||||
_install_fake_client(monkeypatch, redirect_from=raw, redirect_to=METADATA)
|
||||
with pytest.raises(SkillImportError, match="blocked"):
|
||||
parse_skill_source(raw)
|
||||
assert checked == [raw, METADATA]
|
||||
|
||||
|
||||
# --- Positive: a legitimate public->public redirect is still followed --------
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Pin the self-termination contract of the canvas spinners in static/js/spinner.js.
|
||||
|
||||
Background: the whirlpool spinner drives itself with requestAnimationFrame and
|
||||
decides whether to keep going by looking at `element.isConnected`. It used to
|
||||
re-arm forever whenever the element had *never* been connected, on the theory
|
||||
that start() runs before the caller appends the element. Callers that start a
|
||||
spinner and then take an early return - an aborted request, a panel that
|
||||
resolved from cache before the loading row was inserted - therefore left a rAF
|
||||
loop redrawing an 84-segment spiral into a detached canvas until the tab closed.
|
||||
Measured on an idle app: ~110 whirlpool frames per second with zero canvases in
|
||||
the document.
|
||||
|
||||
These tests lock in all four exits (never attached, attached-then-removed,
|
||||
stop(), tab hidden) and, just as importantly, the one case that must NOT stop:
|
||||
a spinner that is actually on screen.
|
||||
|
||||
Driven through `node --input-type=module` so the real module runs, same idiom as
|
||||
test_esc_menu_stack_js.py. The module source is inlined rather than imported by
|
||||
path because the repo has no `"type": "module"` in package.json; spinner.js has
|
||||
no imports of its own, so inlining is exact. A fake clock and a manual frame
|
||||
pump replace performance.now()/requestAnimationFrame, so nothing here depends on
|
||||
wall-clock time or real frame timing.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_MODULE = _REPO / "static" / "js" / "spinner.js"
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
_SRC = _MODULE.read_text(encoding="utf-8") if _MODULE.exists() else ""
|
||||
|
||||
# Browser stand-ins, installed before the module body runs. `clock` is advanced
|
||||
# only by pump(), so every timing decision in the module is deterministic.
|
||||
_STUBS = r"""
|
||||
let clock = 0;
|
||||
Object.defineProperty(globalThis, 'performance', {
|
||||
value: { now: () => clock }, configurable: true, writable: true,
|
||||
});
|
||||
|
||||
const pending = new Map();
|
||||
let nextFrameId = 1;
|
||||
let framesRun = 0;
|
||||
globalThis.requestAnimationFrame = (cb) => {
|
||||
const id = nextFrameId++;
|
||||
pending.set(id, cb);
|
||||
return id;
|
||||
};
|
||||
globalThis.cancelAnimationFrame = (id) => { pending.delete(id); };
|
||||
|
||||
/** Advance the clock `steps` frames of `msPerFrame` and run whatever is queued. */
|
||||
function pump(steps, msPerFrame = 16) {
|
||||
for (let i = 0; i < steps; i++) {
|
||||
clock += msPerFrame;
|
||||
const due = [...pending.values()];
|
||||
pending.clear();
|
||||
for (const cb of due) { framesRun++; cb(); }
|
||||
}
|
||||
}
|
||||
function framesPending() { return pending.size; }
|
||||
function framesSince(mark) { return framesRun - mark; }
|
||||
function frameMark() { return framesRun; }
|
||||
|
||||
function makeCtx() {
|
||||
const noop = () => {};
|
||||
return {
|
||||
clearRect: noop, beginPath: noop, arc: noop, moveTo: noop, lineTo: noop,
|
||||
stroke: noop, fill: noop, save: noop, restore: noop,
|
||||
strokeStyle: '', fillStyle: '', lineWidth: 0, globalAlpha: 1,
|
||||
lineCap: '', lineJoin: '',
|
||||
};
|
||||
}
|
||||
|
||||
function makeElement(tag) {
|
||||
const el = {
|
||||
tagName: tag, className: '', textContent: '', innerHTML: '',
|
||||
width: 0, height: 0, isConnected: false, parentNode: null,
|
||||
style: { cssText: '' },
|
||||
children: [],
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
||||
getContext: () => makeCtx(),
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
removeChild(child) {
|
||||
this.children = this.children.filter((c) => c !== child);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const docListeners = [];
|
||||
globalThis.document = {
|
||||
hidden: false,
|
||||
documentElement: makeElement('html'),
|
||||
createElement: makeElement,
|
||||
createTextNode: (t) => ({ textContent: t }),
|
||||
addEventListener: (type, fn) => { docListeners.push([type, fn]); },
|
||||
removeEventListener: (type, fn) => {
|
||||
const i = docListeners.findIndex(([t, f]) => t === type && f === fn);
|
||||
if (i >= 0) docListeners.splice(i, 1);
|
||||
},
|
||||
};
|
||||
globalThis.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
|
||||
function visibilityListeners() {
|
||||
return docListeners.filter(([t]) => t === 'visibilitychange').length;
|
||||
}
|
||||
function fireVisibility(hidden) {
|
||||
document.hidden = hidden;
|
||||
for (const [t, fn] of [...docListeners]) if (t === 'visibilitychange') fn();
|
||||
}
|
||||
|
||||
/** A started whirlpool spinner whose element is not in the document. */
|
||||
function startedWhirlpool() {
|
||||
const sp = new Spinner('', 'clean', 'whirlpool');
|
||||
sp.createElement();
|
||||
sp.start();
|
||||
return sp;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _run(body: str) -> dict:
|
||||
"""Run `body` with the real spinner module and the browser stubs in scope."""
|
||||
js = _STUBS + "\n" + _SRC + "\n" + body
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=js, capture_output=True, text=True, encoding="utf-8",
|
||||
cwd=str(_REPO), timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_never_attached_whirlpool_stops_itself():
|
||||
# The leak: element created, spinner started, element never inserted. Past
|
||||
# the grace window it must give up rather than re-arm forever.
|
||||
body = """
|
||||
const sp = startedWhirlpool();
|
||||
pump(30); // 480 ms - inside the grace window
|
||||
const early = { running: sp.isRunning, pending: framesPending() };
|
||||
pump(120); // ~2.4 s total - past the grace window
|
||||
const mark = frameMark();
|
||||
pump(60); // nothing should be left to run
|
||||
console.log(JSON.stringify({
|
||||
early,
|
||||
running: sp.isRunning,
|
||||
rafId: sp.rafId,
|
||||
pending: framesPending(),
|
||||
framesAfterStop: framesSince(mark),
|
||||
}));
|
||||
"""
|
||||
out = _run(body)
|
||||
assert out["early"] == {"running": True, "pending": 1}, "gave up during the grace window"
|
||||
assert out["running"] is False
|
||||
assert out["rafId"] is None
|
||||
assert out["pending"] == 0
|
||||
assert out["framesAfterStop"] == 0, "loop kept drawing after it gave up"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_attached_whirlpool_keeps_running_past_the_grace_window():
|
||||
# The converse guard: the fix must not kill spinners that are on screen.
|
||||
body = """
|
||||
const sp = new Spinner('', 'clean', 'whirlpool');
|
||||
sp.createElement();
|
||||
sp.element.isConnected = true;
|
||||
sp.start();
|
||||
pump(400); // ~6.4 s, far past the grace window
|
||||
const mark = frameMark();
|
||||
pump(10);
|
||||
console.log(JSON.stringify({
|
||||
running: sp.isRunning,
|
||||
pending: framesPending(),
|
||||
framesDrawn: framesSince(mark),
|
||||
}));
|
||||
"""
|
||||
out = _run(body)
|
||||
assert out["running"] is True
|
||||
assert out["pending"] == 1
|
||||
assert out["framesDrawn"] == 10, "a visible spinner stopped animating"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_attached_then_removed_whirlpool_stops():
|
||||
# The pre-existing exit - a loading row replaced by results - still works.
|
||||
body = """
|
||||
const sp = new Spinner('', 'clean', 'whirlpool');
|
||||
sp.createElement();
|
||||
sp.element.isConnected = true;
|
||||
sp.start();
|
||||
pump(200);
|
||||
const whileAttached = sp.isRunning;
|
||||
sp.element.isConnected = false; // results arrived, row swapped out
|
||||
pump(3);
|
||||
const mark = frameMark();
|
||||
pump(20);
|
||||
console.log(JSON.stringify({
|
||||
whileAttached,
|
||||
running: sp.isRunning,
|
||||
pending: framesPending(),
|
||||
framesAfterRemoval: framesSince(mark),
|
||||
}));
|
||||
"""
|
||||
out = _run(body)
|
||||
assert out["whileAttached"] is True
|
||||
assert out["running"] is False
|
||||
assert out["pending"] == 0
|
||||
assert out["framesAfterRemoval"] == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_loading_row_helper_stops_when_the_row_is_never_inserted():
|
||||
# createLoadingRow() starts the spinner for the caller and hands back a
|
||||
# detached row, so a caller that early-returns is the real leak shape.
|
||||
body = """
|
||||
const row = createLoadingRow('Loading...', 16);
|
||||
pump(200);
|
||||
const mark = frameMark();
|
||||
pump(40);
|
||||
console.log(JSON.stringify({
|
||||
pending: framesPending(),
|
||||
framesAfterStop: framesSince(mark),
|
||||
rowHasChildren: row.children.length > 0,
|
||||
}));
|
||||
"""
|
||||
out = _run(body)
|
||||
assert out["rowHasChildren"] is True, "harness built the wrong row"
|
||||
assert out["pending"] == 0
|
||||
assert out["framesAfterStop"] == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_stop_cancels_the_pending_frame_and_releases_the_listener():
|
||||
# stop() must be authoritative: no queued frame survives it, and it leaves
|
||||
# no visibilitychange listener behind on a dead spinner.
|
||||
body = """
|
||||
const before = visibilityListeners();
|
||||
const sp = new Spinner('', 'clean', 'whirlpool');
|
||||
sp.createElement();
|
||||
sp.element.isConnected = true;
|
||||
sp.start();
|
||||
const armed = visibilityListeners();
|
||||
sp.stop();
|
||||
const mark = frameMark();
|
||||
pump(20);
|
||||
console.log(JSON.stringify({
|
||||
before, armed, after: visibilityListeners(),
|
||||
running: sp.isRunning,
|
||||
rafId: sp.rafId,
|
||||
pending: framesPending(),
|
||||
framesAfterStop: framesSince(mark),
|
||||
}));
|
||||
"""
|
||||
out = _run(body)
|
||||
assert (out["before"], out["armed"], out["after"]) == (0, 1, 0)
|
||||
assert out["running"] is False
|
||||
assert out["rafId"] is None
|
||||
assert out["pending"] == 0
|
||||
assert out["framesAfterStop"] == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_hidden_tab_pauses_frames_and_showing_resumes_them():
|
||||
body = """
|
||||
const sp = new Spinner('', 'clean', 'whirlpool');
|
||||
sp.createElement();
|
||||
sp.element.isConnected = true;
|
||||
sp.start();
|
||||
pump(5);
|
||||
fireVisibility(true);
|
||||
const hiddenMark = frameMark();
|
||||
pump(30);
|
||||
const whileHidden = { drawn: framesSince(hiddenMark), pending: framesPending() };
|
||||
fireVisibility(false);
|
||||
const shownMark = frameMark();
|
||||
pump(10);
|
||||
console.log(JSON.stringify({
|
||||
whileHidden,
|
||||
running: sp.isRunning,
|
||||
drawnAfterShow: framesSince(shownMark),
|
||||
}));
|
||||
"""
|
||||
out = _run(body)
|
||||
assert out["whileHidden"] == {"drawn": 0, "pending": 0}, "kept drawing in a hidden tab"
|
||||
assert out["running"] is True
|
||||
assert out["drawnAfterShow"] == 10, "did not resume when the tab came back"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_restarted_spinner_gets_a_fresh_grace_window():
|
||||
# The grace deadline is per-run. A spinner reused after stop() must not
|
||||
# inherit the previous run's timestamp and die on its first frame.
|
||||
body = """
|
||||
const sp = startedWhirlpool();
|
||||
pump(200); // times out, never attached
|
||||
const stopped = sp.isRunning;
|
||||
sp.element.isConnected = true; // now inserted for real
|
||||
sp.start();
|
||||
pump(30);
|
||||
console.log(JSON.stringify({
|
||||
stopped,
|
||||
running: sp.isRunning,
|
||||
pending: framesPending(),
|
||||
}));
|
||||
"""
|
||||
out = _run(body)
|
||||
assert out["stopped"] is False
|
||||
assert out["running"] is True
|
||||
assert out["pending"] == 1
|
||||
@@ -21,10 +21,17 @@ def test_call_teacher_scopes_model_resolution_to_owner(monkeypatch):
|
||||
return ("http://endpoint.local/v1", "teacher-model", {})
|
||||
|
||||
async def fake_llm_call_async(url, model, messages, **kwargs):
|
||||
seen["messages"] = messages
|
||||
return "teacher reply"
|
||||
|
||||
from src.agent_tools import model_interaction_tools
|
||||
|
||||
monkeypatch.setattr("src.ai_interaction._resolve_model", fake_resolve_model)
|
||||
monkeypatch.setattr("src.ai_interaction._TEACHER_SYSTEM_PROMPT", "sys", raising=False)
|
||||
monkeypatch.setattr(
|
||||
model_interaction_tools,
|
||||
"_TEACHER_SYSTEM_PROMPT",
|
||||
"sys",
|
||||
)
|
||||
monkeypatch.setattr("src.llm_core.llm_call_async", fake_llm_call_async)
|
||||
|
||||
result = asyncio.run(
|
||||
@@ -34,6 +41,7 @@ def test_call_teacher_scopes_model_resolution_to_owner(monkeypatch):
|
||||
assert result == "teacher reply"
|
||||
assert seen["owner"] == "alice"
|
||||
assert seen["spec"] == "teacher-model"
|
||||
assert seen["messages"][0] == {"role": "system", "content": "sys"}
|
||||
|
||||
|
||||
def test_audit_teacher_resolution_scoped_to_owner(monkeypatch):
|
||||
|
||||
@@ -34,6 +34,7 @@ KEPT = [
|
||||
("append end", "append end"),
|
||||
("END", "END"),
|
||||
("\nEnd\n", "End"),
|
||||
("x assistant y", "x assistant y"), # mid-sentence must survive (#5971)
|
||||
]
|
||||
|
||||
# Real markers — at least one pipe, plus the role word — with the exact output
|
||||
@@ -44,7 +45,9 @@ STRIPPED = [
|
||||
("a /|end| b", "a b"),
|
||||
("a |end b", "a b"),
|
||||
("a end| b", "a b"),
|
||||
("x assistant y", "x y"),
|
||||
("Before\nassistant\nAfter", "Before \nAfter"), # bare-marker on its own line still stripped
|
||||
("Before\n assistant\t \nAfter", "Before \nAfter"), # whitespace-padded marker still stripped
|
||||
("Before\n\tassistan \nAfter", "Before \nAfter"), # truncated marker variant still stripped
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Qwen/Hermes text-mode tool calls: bare JSON inside <tool_call> wrappers.
|
||||
|
||||
Issue #5187: <tool_call>{"name": "bash", "arguments": {...}}</tool_call>
|
||||
parsed to zero blocks because wrapper bodies were only fed to the XML
|
||||
iterators. The JSON body form now parses through the same canonical
|
||||
function_call_to_tool_block converter as the XML paths, and JSON-looking
|
||||
bodies fail closed instead of falling through to XML scanning (tracker #5333):
|
||||
XML-like text inside JSON argument values must stay data, and a non-object
|
||||
"arguments" value is rejected rather than coerced.
|
||||
"""
|
||||
import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
|
||||
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks
|
||||
|
||||
# Verbatim payload from issue #5187.
|
||||
ISSUE_PAYLOAD = '<tool_call>\n{"name": "bash", "arguments": {"command": "mkdir -p agent-test"}}\n</tool_call>'
|
||||
|
||||
|
||||
def test_issue_5187_payload_parses():
|
||||
blocks = parse_tool_blocks(ISSUE_PAYLOAD)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].tool_type == "bash"
|
||||
assert blocks[0].content == "mkdir -p agent-test"
|
||||
|
||||
|
||||
def test_multiple_sequential_wrappers():
|
||||
text = (
|
||||
'<tool_call>\n{"name": "bash", "arguments": {"command": "ls"}}\n</tool_call>\n'
|
||||
'Now the second step:\n'
|
||||
'<tool_call>\n{"name": "bash", "arguments": {"command": "pwd"}}\n</tool_call>'
|
||||
)
|
||||
blocks = parse_tool_blocks(text)
|
||||
assert [(b.tool_type, b.content) for b in blocks] == [("bash", "ls"), ("bash", "pwd")]
|
||||
|
||||
|
||||
def test_unclosed_wrapper_still_parses():
|
||||
text = '<tool_call>\n{"name": "bash", "arguments": {"command": "ls -la"}}'
|
||||
blocks = parse_tool_blocks(text)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].tool_type == "bash"
|
||||
assert blocks[0].content == "ls -la"
|
||||
|
||||
|
||||
def test_xml_inside_json_arguments_stays_data():
|
||||
# P1: a valid JSON body whose argument values contain XML-like tool markup
|
||||
# must parse as the JSON-named tool; the embedded markup is content.
|
||||
text = (
|
||||
'<tool_call>{"name": "write_file", "arguments": '
|
||||
'{"path": "notes.txt", "content": "<bash>echo unsafe</bash>"}}</tool_call>'
|
||||
)
|
||||
blocks = parse_tool_blocks(text)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].tool_type == "write_file"
|
||||
assert "<bash>echo unsafe</bash>" in blocks[0].content
|
||||
assert all(b.tool_type != "bash" for b in blocks)
|
||||
|
||||
|
||||
def test_malformed_json_body_never_falls_through_to_xml():
|
||||
# P1 fail-closed: a JSON-looking body that doesn't decode must not be
|
||||
# rescanned as XML, even when it contains well-formed tool markup.
|
||||
text = (
|
||||
'<tool_call>{"name": "write_file", "arguments": {broken json '
|
||||
'<invoke name="bash"><parameter name="command">echo unsafe</parameter></invoke>'
|
||||
'</tool_call>'
|
||||
)
|
||||
assert parse_tool_blocks(text) == []
|
||||
|
||||
|
||||
def test_non_dict_arguments_rejected():
|
||||
# P2: "arguments" must be an object; scalars/arrays are rejected, not coerced.
|
||||
for args in ('["ls"]', '"ls"', '1', 'null'):
|
||||
text = '<tool_call>{"name": "bash", "arguments": %s}</tool_call>' % args
|
||||
assert parse_tool_blocks(text) == [], f"arguments={args} should be rejected"
|
||||
|
||||
|
||||
def test_strip_tool_blocks_removes_json_wrapper_spans():
|
||||
text = "Before.\n" + ISSUE_PAYLOAD + "\nAfter."
|
||||
cleaned = strip_tool_blocks(text)
|
||||
assert "tool_call" not in cleaned
|
||||
assert "mkdir -p agent-test" not in cleaned
|
||||
assert "Before." in cleaned
|
||||
assert "After." in cleaned
|
||||
|
||||
|
||||
def test_xml_body_wrapper_regression():
|
||||
# The pre-existing XML wrapper form must keep parsing exactly as before.
|
||||
text = (
|
||||
'<tool_call><invoke name="bash">'
|
||||
'<parameter name="command">echo hi</parameter>'
|
||||
'</invoke></tool_call>'
|
||||
)
|
||||
blocks = parse_tool_blocks(text)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0].tool_type == "bash"
|
||||
assert blocks[0].content == "echo hi"
|
||||
@@ -6,8 +6,15 @@ Verifies two critical cases:
|
||||
2. api.deepseek.com must still be treated as tool-capable via the host
|
||||
allow-list (_API_HOSTS), so cloud deepseek users keep working.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from src.agent_loop import _API_HOSTS, _endpoint_lookup_keys, _is_ollama_openai_compat_url
|
||||
from src.agent_loop import (
|
||||
_API_HOSTS,
|
||||
_agent_route_tool_mode,
|
||||
_endpoint_lookup_keys,
|
||||
_is_ollama_openai_compat_url,
|
||||
)
|
||||
from src.llm_core import _is_ollama_native_url
|
||||
|
||||
|
||||
@@ -164,3 +171,57 @@ class TestEndpointLookupKeys:
|
||||
keys = _endpoint_lookup_keys("http://host.docker.internal:11434/api/chat")
|
||||
|
||||
assert "http://host.docker.internal:11434/api" in keys
|
||||
|
||||
|
||||
def test_route_tool_mode_matches_credential_distinct_endpoint(monkeypatch):
|
||||
from core import database
|
||||
from src import endpoint_resolver
|
||||
|
||||
rows = [
|
||||
SimpleNamespace(
|
||||
id="one",
|
||||
base_url="https://same.example/v1",
|
||||
api_key="key-one",
|
||||
provider_auth_id=None,
|
||||
supports_tools=True,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="two",
|
||||
base_url="https://same.example/v1",
|
||||
api_key="key-two",
|
||||
provider_auth_id=None,
|
||||
supports_tools=False,
|
||||
),
|
||||
]
|
||||
|
||||
class Query:
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return rows
|
||||
|
||||
class Db:
|
||||
def query(self, *args, **kwargs):
|
||||
return Query()
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(database, "SessionLocal", lambda: Db())
|
||||
monkeypatch.setattr(
|
||||
endpoint_resolver,
|
||||
"resolve_endpoint_runtime",
|
||||
lambda endpoint, owner=None: (endpoint.base_url, endpoint.api_key),
|
||||
)
|
||||
|
||||
assert _agent_route_tool_mode(
|
||||
"https://same.example/v1",
|
||||
"custom-model",
|
||||
headers={"Authorization": "Bearer key-one"},
|
||||
)[0] is True
|
||||
assert _agent_route_tool_mode(
|
||||
"https://same.example/v1",
|
||||
"custom-model",
|
||||
headers={"Authorization": "Bearer key-two"},
|
||||
)[0] is False
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
|
||||
|
||||
|
||||
def _node_eval(source):
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module", "-e", source],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _resolve(state):
|
||||
"""Run resolveVisibility(state) in node; return {selector: visible}."""
|
||||
return _node_eval(
|
||||
f"""
|
||||
const {{ resolveVisibility }} = await import('./static/js/ui_visibility.js');
|
||||
console.log(JSON.stringify(resolveVisibility({json.dumps(state)})));
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _map():
|
||||
return _node_eval(
|
||||
"""
|
||||
const { UI_VIS_MAP } = await import('./static/js/ui_visibility.js');
|
||||
console.log(JSON.stringify(UI_VIS_MAP));
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# Selectors (kept in one place so the tests read as plain assertions).
|
||||
EMAIL = "#email-section, #rail-email"
|
||||
TOOLS = "#tools-section"
|
||||
CAL = "#tool-calendar-btn, #rail-calendar"
|
||||
COMPARE = "#tool-compare-btn, #rail-compare"
|
||||
LIB = "#tool-library-btn, #rail-archive"
|
||||
RESEARCH = "#tool-research-btn, #rail-research"
|
||||
NEWCHAT = "#rail-new-session"
|
||||
RAG = "#overflow-rag-btn"
|
||||
|
||||
# Full-sidebar tabs that have an icon-rail counterpart must pair it into their
|
||||
# UI_VIS_MAP selector; otherwise minimizing the sidebar re-shows a tab the user
|
||||
# turned off in the full view (#tool-library-btn's rail counterpart is #rail-archive).
|
||||
EXPECTED_RAIL_PAIRS = {
|
||||
"email-section": "#rail-email",
|
||||
"tool-calendar": "#rail-calendar",
|
||||
"tool-compare": "#rail-compare",
|
||||
"tool-cookbook": "#rail-cookbook",
|
||||
"tool-research": "#rail-research",
|
||||
"tool-gallery": "#rail-gallery",
|
||||
"tool-library": "#rail-archive",
|
||||
"tool-memory": "#rail-memory",
|
||||
"tool-notes": "#rail-notes",
|
||||
"tool-tasks": "#rail-tasks",
|
||||
"tool-theme": "#rail-theme",
|
||||
}
|
||||
|
||||
|
||||
def test_every_customizable_tab_pairs_its_rail_button():
|
||||
ui_vis_map = _map()
|
||||
missing = {
|
||||
key: rail
|
||||
for key, rail in EXPECTED_RAIL_PAIRS.items()
|
||||
if rail not in ui_vis_map.get(key, "")
|
||||
}
|
||||
assert not missing, (
|
||||
"these tabs are missing their icon-rail counterpart in UI_VIS_MAP "
|
||||
f"(minimizing the sidebar would re-show them): {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_defaults_everything_visible_except_default_off():
|
||||
m = _resolve({})
|
||||
assert m[EMAIL] is True
|
||||
assert m[TOOLS] is True
|
||||
assert m[CAL] is True
|
||||
assert m[NEWCHAT] is True
|
||||
assert m[RAG] is False # rag-toggle-btn is default-off
|
||||
|
||||
|
||||
def test_email_off_hides_email_and_its_rail_only():
|
||||
m = _resolve({"email-section": False})
|
||||
assert m[EMAIL] is False
|
||||
assert m[CAL] is True
|
||||
assert m[TOOLS] is True
|
||||
|
||||
|
||||
def test_tool_off_hides_its_rail_launcher():
|
||||
m = _resolve({"tool-calendar": False})
|
||||
assert m[CAL] is False
|
||||
assert m[COMPARE] is True
|
||||
|
||||
|
||||
def test_library_off_hides_archive_rail():
|
||||
# tool-library's rail counterpart is #rail-archive (mirrors _railToolMap).
|
||||
m = _resolve({"tool-library": False})
|
||||
assert m[LIB] is False
|
||||
|
||||
|
||||
def test_tools_off_hides_every_tool_rail_but_not_email():
|
||||
m = _resolve({"tools-section": False})
|
||||
assert m[TOOLS] is False
|
||||
for sel in (CAL, COMPARE, LIB, RESEARCH):
|
||||
assert m[sel] is False, sel
|
||||
assert m[EMAIL] is True # email is independent of the Tools section
|
||||
|
||||
|
||||
def test_tools_off_overrides_per_tool_on():
|
||||
# A tool individually "on" must still hide when its parent Tools is off.
|
||||
m = _resolve({"tools-section": False, "tool-calendar": True})
|
||||
assert m[CAL] is False
|
||||
|
||||
|
||||
def test_tools_on_with_tool_off_hides_only_that_tool():
|
||||
m = _resolve({"tools-section": True, "tool-research": False})
|
||||
assert m[RESEARCH] is False
|
||||
assert m[CAL] is True
|
||||
|
||||
|
||||
def test_rail_new_chat_off_hides_new_session():
|
||||
m = _resolve({"rail-new-chat": False})
|
||||
assert m[NEWCHAT] is False
|
||||
|
||||
|
||||
def test_explicit_false_takes_precedence_over_default_on():
|
||||
m = _resolve({"rag-toggle-btn": True})
|
||||
assert m[RAG] is True
|
||||
@@ -15,6 +15,7 @@ These tests exercise:
|
||||
* Smoke tests: normal upload, duplicate detection, info lookup after
|
||||
a backup-recovery scenario.
|
||||
"""
|
||||
import builtins
|
||||
import concurrent.futures
|
||||
import io
|
||||
import json
|
||||
@@ -59,6 +60,16 @@ def _db_path(handler: UploadHandler) -> str:
|
||||
return os.path.join(handler.upload_dir, "uploads.json")
|
||||
|
||||
|
||||
def _truncate_without_newer_mtime(path: str) -> None:
|
||||
"""Model a filesystem where a torn write shares the cached timestamp."""
|
||||
before = os.stat(path)
|
||||
with open(path, "rb") as f:
|
||||
full = f.read()
|
||||
with open(path, "wb") as f:
|
||||
f.write(full[: max(1, len(full) // 2)])
|
||||
os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns))
|
||||
|
||||
|
||||
def _seed_entry(owner: str, file_hash: str, file_id: str) -> dict:
|
||||
return {
|
||||
"id": file_id,
|
||||
@@ -246,10 +257,7 @@ def test_partial_write_recovery_via_bak(tmp_path):
|
||||
"Production _atomic_write_json must create a .bak sibling on subsequent writes."
|
||||
)
|
||||
|
||||
full = open(db_path, "rb").read()
|
||||
truncated_len = max(1, len(full) // 2)
|
||||
with open(db_path, "wb") as f:
|
||||
f.write(full[:truncated_len])
|
||||
_truncate_without_newer_mtime(db_path)
|
||||
|
||||
recovered = handler._load_upload_index()
|
||||
missing = [k for k in original if k not in recovered]
|
||||
@@ -259,6 +267,69 @@ def test_partial_write_recovery_via_bak(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
def test_partial_write_recovery_via_bak_after_restart(tmp_path):
|
||||
"""A fresh handler must recover the previous snapshot from ``.bak``."""
|
||||
handler = _make_handler(tmp_path)
|
||||
db_path = _db_path(handler)
|
||||
original = {
|
||||
f"owner:hash_{i}": _seed_entry("owner", f"hash_{i}", f"id_{i}")
|
||||
for i in range(3)
|
||||
}
|
||||
handler._atomic_write_json(db_path, original)
|
||||
handler._atomic_write_json(db_path, {"latest": True})
|
||||
_truncate_without_newer_mtime(db_path)
|
||||
|
||||
restarted_handler = UploadHandler(
|
||||
base_dir=handler.base_dir,
|
||||
upload_dir=handler.upload_dir,
|
||||
)
|
||||
|
||||
assert restarted_handler._load_upload_index() == original
|
||||
|
||||
|
||||
def test_unchanged_upload_index_uses_cache(tmp_path, monkeypatch):
|
||||
"""The stronger file signature must preserve the unchanged-index fast path."""
|
||||
handler = _make_handler(tmp_path)
|
||||
original = {"owner:hash": _seed_entry("owner", "hash", "id")}
|
||||
handler._atomic_write_json(_db_path(handler), original)
|
||||
|
||||
def fail_if_parsed(_file):
|
||||
raise AssertionError("unchanged upload index should be served from cache")
|
||||
|
||||
monkeypatch.setattr(json, "load", fail_if_parsed)
|
||||
|
||||
assert handler._load_upload_index() == original
|
||||
|
||||
|
||||
def test_upload_index_retries_when_replaced_during_read(tmp_path, monkeypatch):
|
||||
"""Do not cache old JSON under the signature of a newer atomic replace."""
|
||||
handler = _make_handler(tmp_path)
|
||||
db_path = _db_path(handler)
|
||||
old_index = {"owner:old": _seed_entry("owner", "old", "old_id")}
|
||||
new_index = {"owner:new": _seed_entry("owner", "new", "new_id")}
|
||||
handler._atomic_write_json(db_path, old_index)
|
||||
handler._index_cache = None
|
||||
handler._index_signature = None
|
||||
|
||||
real_open = builtins.open
|
||||
replaced = False
|
||||
|
||||
def racing_open(file, mode="r", *args, **kwargs):
|
||||
nonlocal replaced
|
||||
handle = real_open(file, mode, *args, **kwargs)
|
||||
if os.fspath(file) == db_path and "r" in mode and not replaced:
|
||||
replaced = True
|
||||
replacement = db_path + ".replacement"
|
||||
with real_open(replacement, "w", encoding="utf-8") as out:
|
||||
json.dump(new_index, out)
|
||||
os.replace(replacement, db_path)
|
||||
return handle
|
||||
|
||||
monkeypatch.setattr(builtins, "open", racing_open)
|
||||
|
||||
assert handler._load_upload_index() == new_index
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atomicity primitive audit on the production module.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -390,10 +461,8 @@ def test_smoke_info_lookup_after_bak_recovery(tmp_path):
|
||||
handler._atomic_write_json(db_path, {"sentinel": True})
|
||||
assert os.path.exists(db_path + ".bak")
|
||||
|
||||
# Truncate the live file.
|
||||
full = open(db_path, "rb").read()
|
||||
with open(db_path, "wb") as f:
|
||||
f.write(full[: max(1, len(full) // 2)])
|
||||
# Truncate the live file without assuming the filesystem advances mtime.
|
||||
_truncate_without_newer_mtime(db_path)
|
||||
|
||||
info = handler.get_upload_info(first["id"])
|
||||
assert info is not None, "Info lookup must succeed after .bak recovery."
|
||||
|
||||
@@ -117,6 +117,31 @@ def test_agent_system_prompt_includes_shared_current_time(monkeypatch):
|
||||
assert "Australia/Brisbane, UTC+10:00" in datetime_messages[0]["content"]
|
||||
|
||||
|
||||
def test_route_prompt_rebuild_restores_leading_user_system_message(monkeypatch):
|
||||
import src.agent_loop as agent_loop
|
||||
|
||||
monkeypatch.setattr(agent_loop, "_build_base_prompt", lambda *args, **kwargs: ("AGENT PROMPT", ""))
|
||||
monkeypatch.setattr(agent_loop, "set_active_model", lambda model: None)
|
||||
monkeypatch.setattr(agent_loop, "get_builtin_overrides", lambda: {})
|
||||
monkeypatch.setattr(agent_loop, "_cached_base_prompt", None)
|
||||
monkeypatch.setattr(agent_loop, "_cached_base_prompt_key", None)
|
||||
|
||||
original = [
|
||||
{"role": "system", "content": "USER PERSONA"},
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
built, _ = agent_loop._build_system_prompt(
|
||||
original,
|
||||
model="selected-model",
|
||||
active_document=None,
|
||||
mcp_mgr=None,
|
||||
)
|
||||
|
||||
assert built[0]["content"] == "USER PERSONA\n\nAGENT PROMPT"
|
||||
assert built[0]["_agent_injected"] == "merged_prompt"
|
||||
assert agent_loop._strip_agent_injected_messages(built) == original
|
||||
|
||||
|
||||
def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
|
||||
import routes.calendar_routes as calendar_routes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user