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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/lib/chatSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ export function initChatSession() {
if (!text) return;
const profileName = event.payload?.profile || activeProfile;
const target = session(profileName);
const threadId = event.payload?.thread_id;
if (threadId && target.activeThreadId && threadId !== target.activeThreadId) {
loadThreads(profileName, target).catch(() => {});
return;
}
if (target.messages[target.messages.length - 1]?.text === text) return;
target.messages.push({ who: "d", text, ts: new Date().toISOString() });
publish(target);
Expand Down
22 changes: 22 additions & 0 deletions server/dante/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,27 @@ def _migration_37_stable_fixed_identifiers(c: sqlite3.Connection) -> None:
)


def _migration_38_chat_log_threads(c: sqlite3.Connection) -> None:
"""Bind proactive chat messages to the thread active when they were created."""
_add_column(c, "chat_log", "thread_id", "TEXT")
_add_column(c, "notifications", "thread_id", "TEXT")
# Legacy notifications predate user-created threads. Keep them in their
# former day transcript instead of leaking them into every future thread.
c.execute(
"UPDATE chat_log SET thread_id=profile || ':' || date(created,'unixepoch') "
"WHERE thread_id IS NULL AND profile IS NOT NULL"
)
c.execute(
"UPDATE notifications "
"SET thread_id=profile || ':' || date(created_at,'unixepoch') "
"WHERE thread_id IS NULL"
)
c.execute(
"CREATE INDEX IF NOT EXISTS idx_chat_log_thread "
"ON chat_log(profile,thread_id,created)"
)


_MIGRATIONS = ((1, _migration_1_legacy_columns), (2, _migration_2_chat_requests),
(3, _migration_3_shopping_scopes), (4, _migration_4_chat_runs),
(5, _migration_5_chat_run_indexes), (6, _migration_6_chat_approval),
Expand Down Expand Up @@ -1816,6 +1837,7 @@ def _migration_37_stable_fixed_identifiers(c: sqlite3.Connection) -> None:
_MIGRATIONS += ((35, _migration_35_profile_integrations),)
_MIGRATIONS += ((36, _migration_36_notifications),)
_MIGRATIONS += ((37, _migration_37_stable_fixed_identifiers),)
_MIGRATIONS += ((38, _migration_38_chat_log_threads),)


def init() -> None:
Expand Down
19 changes: 12 additions & 7 deletions server/dante/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import uuid
from typing import Any

from . import db, profile_directory
from . import chat_runs, db, profile_directory
from .bus import bus
from .hub import hub

Expand Down Expand Up @@ -48,6 +48,7 @@ def notify(text: str, profile: str | None = None, category: str | None = None,
category_key = str(category or "").strip().lower()
normalized_category = _CATEGORY_ALIASES.get(category_key, category_key) or None
chat_category = normalized_category or "general"
chat_thread_id = chat_runs.effective_session(selected)
normalized_title = str(title).strip() if title is not None else None
normalized_title = normalized_title or None
normalized_event_type = str(event_type or "chat.message").strip() or "chat.message"
Expand All @@ -67,7 +68,7 @@ def notify(text: str, profile: str | None = None, category: str | None = None,

with db.transaction() as connection:
existing = db.one(
"SELECT id,profile,event_type,category,title,body,data_json "
"SELECT id,profile,event_type,category,title,body,data_json,thread_id "
"FROM notifications WHERE event_id=?",
(notification_event_id,),
connection,
Expand All @@ -82,24 +83,27 @@ def notify(text: str, profile: str | None = None, category: str | None = None,
"notification event_id already exists with different content",
)
notification_id = int(existing["id"])
chat_thread_id = existing.get("thread_id") or chat_thread_id
deduplicated = True
else:
notification_id = db.run(
"INSERT INTO notifications("
"event_id,profile,event_type,category,title,body,data_json,created_at"
") VALUES(?,?,?,?,?,?,?,?)",
"event_id,profile,event_type,category,title,body,data_json,thread_id,created_at"
") VALUES(?,?,?,?,?,?,?,?,?)",
(
notification_event_id, selected, normalized_event_type,
normalized_category, normalized_title, body, data_json, time.time(),
normalized_category, normalized_title, body, data_json,
chat_thread_id, time.time(),
),
connection,
)
# Proaktywny dymek nieobecny w transkrypcie Hermesa nadal trafia
# do historii czatu dokładnie tak jak przed dodaniem inboxa.
if persist and normalized_event_type == "chat.message" and body:
db.run(
"INSERT INTO chat_log(profile,category,role,text) VALUES(?,?,?,?)",
(selected, chat_category, "dante", body),
"INSERT INTO chat_log(profile,category,thread_id,role,text) "
"VALUES(?,?,?,?,?)",
(selected, chat_category, chat_thread_id, "dante", body),
connection,
)

Expand All @@ -109,6 +113,7 @@ def notify(text: str, profile: str | None = None, category: str | None = None,
"profile": selected,
"notification_id": notification_id,
"notification_event_id": notification_event_id,
"thread_id": chat_thread_id,
}
if normalized_category:
payload["category"] = normalized_category
Expand Down
44 changes: 28 additions & 16 deletions server/dante/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,15 @@ async def history(
# Dołącz proaktywne wiadomości Dantego (bilans/ocena/podsumowanie) zapisane lokalnie —
# nie ma ich w transkrypcie, więc bez tego ginęły po zamknięciu czatu. Merge po czasie.
try:
# Jeden wątek/użytkownik: proaktywne wiadomości (bilans/ocena/podsumowanie) doklejamy
# BEZ filtra kategorii — inaczej znikałyby ze wspólnego czatu (zapisywane bez/pod różną
# kategorią). Scalają się po czasie z transkryptem dnia.
if selected_session == chat_runs.effective_session(owner):
for p in db.q("SELECT text, created FROM chat_log "
"WHERE profile=? AND role='dante' AND created>=? ORDER BY created",
(owner, visible_since)):
msgs.append({"role": "dante", "text": p["text"], "ts": p.get("created") or 0})
msgs.sort(key=lambda m: m.get("ts") or 0)
# Proaktywne wiadomości są lokalne względem wątku, który był aktywny
# w chwili ich utworzenia. Dzięki temu pozostają w jego historii, ale
# nie wracają po przejściu do nowej rozmowy.
for p in db.q("SELECT text, created FROM chat_log "
"WHERE profile=? AND role='dante' AND thread_id=? "
"AND created>=? ORDER BY created",
(owner, selected_session, visible_since)):
msgs.append({"role": "dante", "text": p["text"], "ts": p.get("created") or 0})
msgs.sort(key=lambda m: m.get("ts") or 0)
except Exception as e: # noqa: BLE001
print(f"[history] proactive-message merge failed: {type(e).__name__}")
# Deduplikujemy tylko sąsiadujące kopie transportowe. Powtórzone później „tak” albo
Expand Down Expand Up @@ -218,19 +218,31 @@ async def threads(profile: str | None = None):
proactive = db.one(
"SELECT COUNT(*) message_count, MAX(created) updated_at, "
"(SELECT text FROM chat_log WHERE profile=? AND role='dante' AND created>=? "
"AND thread_id=? "
"ORDER BY created DESC,id DESC LIMIT 1) preview "
"FROM chat_log WHERE profile=? AND role='dante' AND created>=?",
(owner, visible_since, owner, visible_since),
"FROM chat_log WHERE profile=? AND role='dante' AND created>=? "
"AND thread_id=?",
(owner, visible_since, active_thread_id,
owner, visible_since, active_thread_id),
)
if proactive and int(proactive.get("message_count") or 0):
proactive_count = int((proactive or {}).get("message_count") or 0)
if proactive_count or ":chat-" in active_thread_id:
activation = db.one(
"SELECT updated FROM chat_sessions WHERE profile=? "
"AND effective_session_id=? ORDER BY updated DESC LIMIT 1",
(owner, active_thread_id),
)
result.append({
"id": active_thread_id,
"title": "",
"message_count": int(proactive["message_count"]),
"updated_at": proactive.get("updated_at") or 0,
"preview": proactive.get("preview") or "",
"title": (i18n.translate("labels.new_conversation")
if ":chat-" in active_thread_id else ""),
"message_count": proactive_count,
"updated_at": ((proactive or {}).get("updated_at")
or (activation or {}).get("updated") or 0),
"preview": (proactive or {}).get("preview") or "",
})
result.sort(key=lambda row: str(row.get("updated_at") or ""), reverse=True)
result.sort(key=lambda row: row["id"] != active_thread_id)
return {"threads": result, "active_thread_id": active_thread_id}
except Exception: # noqa: BLE001
return {
Expand Down
55 changes: 51 additions & 4 deletions server/tests/test_chat_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -815,12 +815,12 @@ async def source(_profile):
chat.chat_runs, "effective_session", lambda _owner: "admin:2026-07-30",
)
db.run(
"INSERT INTO chat_log(profile,category,role,text,created) VALUES(?,?,?,?,?)",
("admin", "ogolny", "dante", "Backup wymaga uwagi.", 30),
"INSERT INTO chat_log(profile,category,thread_id,role,text,created) VALUES(?,?,?,?,?,?)",
("admin", "ogolny", "admin:2026-07-30", "dante", "Backup wymaga uwagi.", 30),
)
db.run(
"INSERT INTO chat_log(profile,category,role,text,created) VALUES(?,?,?,?,?)",
("member", "ogolny", "dante", "Prywatna wiadomość.", 40),
"INSERT INTO chat_log(profile,category,thread_id,role,text,created) VALUES(?,?,?,?,?,?)",
("member", "ogolny", "member:2026-07-30", "dante", "Prywatna wiadomość.", 40),
)

result = await chat.threads("admin")
Expand All @@ -835,6 +835,53 @@ async def source(_profile):
}


@pytest.mark.asyncio
async def test_threads_keep_an_empty_active_new_conversation(isolated_db, monkeypatch):
async def source(_profile):
return [
{"session_id": "admin:2026-07-30", "messages": 3, "last_active": 20},
]

monkeypatch.setattr(chat.dante_link, "threads", source)
chat_runs.activate_session("admin", "admin:chat-abcdef0123456789")

result = await chat.threads("admin")

assert result["active_thread_id"] == "admin:chat-abcdef0123456789"
assert result["threads"][0] == {
"id": "admin:chat-abcdef0123456789",
"title": "New conversation",
"message_count": 0,
"updated_at": pytest.approx(time.time(), abs=2),
"preview": "",
}


@pytest.mark.asyncio
async def test_new_conversation_does_not_inherit_proactive_chat_messages(
isolated_db, monkeypatch,
):
async def empty_history(*_args, **_kwargs):
return []

monkeypatch.setattr(chat.dante_link, "history", empty_history)
old_thread = chat_runs.effective_session("admin")
db.run(
"INSERT INTO chat_log(profile,category,thread_id,role,text,created) "
"VALUES(?,?,?,?,?,?)",
("admin", "general", old_thread, "dante", "Stary alert systemowy", 30),
)

created = await chat.new(chat.NewIn(profile="admin"))
new_history = await chat.history("admin")
old_history = await chat.history("admin", thread_id=old_thread)

assert new_history == {"messages": [], "thread_id": created["thread"]["id"]}
assert [message["text"] for message in old_history["messages"]] == [
"Stary alert systemowy",
]


@pytest.mark.asyncio
async def test_history_restores_compacted_runs_without_dropping_repeated_turns(isolated_db, monkeypatch):
async def compacted(*_args, **_kwargs):
Expand Down
8 changes: 7 additions & 1 deletion server/tests/test_integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ def test_todo_due_date_migration_is_applied(isolated_db):
columns = {row["name"] for row in isolated_db.q("PRAGMA table_info(items)")}

assert "due_date" in columns
assert isolated_db.one("SELECT MAX(version) version FROM schema_migrations")["version"] == 37
assert isolated_db.one("SELECT MAX(version) version FROM schema_migrations")["version"] == 38
assert "thread_id" in {
row["name"] for row in isolated_db.q("PRAGMA table_info(chat_log)")
}
assert "thread_id" in {
row["name"] for row in isolated_db.q("PRAGMA table_info(notifications)")
}

proposal_columns = {
row["name"] for row in isolated_db.q("PRAGMA table_info(chat_image_proposals)")
Expand Down
53 changes: 52 additions & 1 deletion server/tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient

from dante import db, device_auth
from dante import chat_runs, db, device_auth
from dante import notify as notification_service
from dante.device_auth_middleware import DeviceAuthMiddleware
from dante.routers import notifications
Expand Down Expand Up @@ -124,6 +124,9 @@ def test_notification_event_id_deduplicates_storage_and_retries_safe_delivery(
assert second["deduplicated"] is True
assert isolated_db.one("SELECT COUNT(*) count FROM notifications")["count"] == 1
assert isolated_db.one("SELECT COUNT(*) count FROM chat_log")["count"] == 1
assert isolated_db.one("SELECT thread_id FROM chat_log")["thread_id"] == (
chat_runs.effective_session("admin")
)
assert len(published) == 2
assert {
kwargs["event_id"] for _args, kwargs in published
Expand All @@ -139,6 +142,54 @@ def test_notification_event_id_deduplicates_storage_and_retries_safe_delivery(
assert isolated_db.one("SELECT COUNT(*) count FROM chat_log")["count"] == 1


def test_chat_notification_is_bound_to_the_active_thread(isolated_db, monkeypatch):
published: list[tuple[tuple, dict]] = []
monkeypatch.setattr(
notification_service.bus,
"publish",
lambda *args, **kwargs: published.append((args, kwargs)),
)
thread_id = "admin:chat-abcdef0123456789"
chat_runs.activate_session("admin", thread_id)

notification_service.notify(
"A thread-local alert",
profile="admin",
event_id="notification-thread-local-1",
)

assert isolated_db.one("SELECT thread_id FROM chat_log")["thread_id"] == thread_id
assert isolated_db.one("SELECT thread_id FROM notifications")["thread_id"] == thread_id
assert published[0][0][1]["thread_id"] == thread_id


def test_notification_retry_stays_bound_to_its_original_thread(isolated_db, monkeypatch):
published: list[tuple[tuple, dict]] = []
monkeypatch.setattr(
notification_service.bus,
"publish",
lambda *args, **kwargs: published.append((args, kwargs)),
)
original = "admin:chat-1111111111111111"
current = "admin:chat-2222222222222222"
chat_runs.activate_session("admin", original)
notification_service.notify(
"Retry-safe alert",
profile="admin",
event_id="notification-thread-retry-1",
)
chat_runs.activate_session("admin", current)

retried = notification_service.notify(
"Retry-safe alert",
profile="admin",
event_id="notification-thread-retry-1",
)

assert retried["deduplicated"] is True
assert [call[0][1]["thread_id"] for call in published] == [original, original]


def test_persist_flag_only_controls_chat_log(isolated_db, monkeypatch):
monkeypatch.setattr(notification_service.bus, "publish", lambda *args, **kwargs: None)

Expand Down