diff --git a/README.md b/README.md index 1ee1194a0..cb111590b 100644 --- a/README.md +++ b/README.md @@ -524,6 +524,8 @@ DeerFlow supports configurable MCP servers and skills to extend its capabilities For HTTP/SSE MCP servers, OAuth token flows are supported (`client_credentials`, `refresh_token`). For stdio MCP servers, per-tool call timeouts can be configured with `tool_call_timeout`; durable background-task calls honor the same setting for HTTP/SSE servers as well. MCP tool names are prefixed with `_` by default to prevent collisions across servers. If a server already namespaces its own tools, set `tool_name_prefix: false` on that server in `extensions_config.json` to keep the original names. Disable the prefix only when the resulting names remain unique across all enabled servers. +Signed-in users' notification toggle, default model, conversation mode, and reasoning effort are saved to their account and restored on other browsers or after clearing browser storage. Browser notification permission still needs to be granted on each device. Changes retry after network failures; unsent changes survive a reload in the same tab. Concurrent edits to different fields are preserved; for the same field, the last server write wins. Existing unscoped browser preferences are not uploaded automatically because they have no account owner; reselect those settings once after upgrading. Static demos and auth-disabled development keep browser-local settings. Thread-specific model overrides and other display preferences remain local. + Settings > Tools adds, replaces, and deletes one MCP server at a time through targeted mutations that preserve concurrent sibling changes; deletes use a bodyless URL-addressed request. An invalid stdio command on one server no longer blocks toggling another, while enabling that invalid server remains protected by the command allowlist and surfaces the backend validation message in the UI. Targeted updates accept both DeerFlow's `type` field and the MCP-spec `transport` field for SSE/HTTP servers. Runtime MCP and skill updates replace `extensions_config.json` atomically, so an interrupted write cannot leave the shared configuration truncated or partially written. diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index b6a43975d..5bc2df75f 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -42,6 +42,7 @@ from app.gateway.routers import ( thread_runs, threads, uploads, + user_preferences, ) from app.gateway.trace_middleware import TraceMiddleware from deerflow.config import app_config as deerflow_app_config @@ -855,6 +856,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for # Auth API is mounted at /api/v1/auth app.include_router(auth.router) + app.include_router(user_preferences.router) # Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback app.include_router(feedback.router) diff --git a/backend/app/gateway/routers/user_preferences.py b/backend/app/gateway/routers/user_preferences.py new file mode 100644 index 000000000..a6ba7bce8 --- /dev/null +++ b/backend/app/gateway/routers/user_preferences.py @@ -0,0 +1,60 @@ +"""Session-authenticated, owner-scoped UI preferences.""" + +from typing import Annotated, Literal + +from fastapi import APIRouter, Header, HTTPException, Request, Response +from pydantic import BaseModel, ConfigDict, StringConstraints, ValidationError + +from app.gateway.auth_disabled import AUTH_SOURCE_SESSION +from app.gateway.deps import get_current_user_from_request +from deerflow.persistence.engine import get_session_factory +from deerflow.persistence.user.preferences import UserPreferencesRepository + +router = APIRouter(prefix="/api/v1/auth/preferences", tags=["auth"]) + + +class Preferences(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + notification_enabled: bool | None = None + model_name: Annotated[str, StringConstraints(max_length=200)] | None = None + mode: Literal["flash", "thinking", "pro", "ultra"] | None = None + reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None + + +async def _owner(request: Request, expected_user: str) -> str: + user = await get_current_user_from_request(request) + if getattr(request.state, "auth_source", None) != AUTH_SOURCE_SESSION: + raise HTTPException(403, "Preferences require an authenticated browser session") + owner = str(user.id) + if owner != expected_user: + raise HTTPException(409, "The signed-in account changed; reload this page") + return owner + + +def _repository() -> UserPreferencesRepository: + sessions = get_session_factory() + if sessions is None: + raise HTTPException(503, "Preference persistence is unavailable") + return UserPreferencesRepository(sessions) + + +@router.get("", response_model=Preferences) +async def get_preferences(request: Request, x_expected_user_id: str = Header(...)) -> Preferences: + owner = await _owner(request, x_expected_user_id) + stored = await _repository().get(owner) + # An invalid optional cosmetic setting must not hide the rest of the page. + valid = {} + for key, value in stored.items(): + try: + Preferences.model_validate({key: value}) + except ValidationError: + continue + valid[key] = value + return Preferences(**valid) + + +@router.patch("", status_code=204, response_class=Response) +async def patch_preferences(body: Preferences, request: Request, x_expected_user_id: str = Header(...)) -> Response: + owner = await _owner(request, x_expected_user_id) + await _repository().patch(owner, body.model_dump(exclude_unset=True)) + return Response(status_code=204) diff --git a/backend/docs/API.md b/backend/docs/API.md index e8ad7e461..763f13ac3 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -34,6 +34,32 @@ PATs require a configured database backend (SQLite/PostgreSQL) — on the memory-only backend, Bearer credentials are rejected and PAT management routes return `503`. +### Account Preferences + +`GET /api/v1/auth/preferences` returns the signed-in browser user's four +preferences. `PATCH` updates only explicitly supplied fields and returns `204`. +Both require `X-Expected-User-Id` matching the session user; PATCH also requires +the normal `X-CSRF-Token` header. The expected ID is a stale-tab guard, not an +authorization credential. PAT, internal, and auth-disabled callers receive +`403`; a different session user receives `409`. + +```json +{ + "notification_enabled": false, + "model_name": "my-model", + "mode": "pro", + "reasoning_effort": "high" +} +``` + +All four fields accept `null` to restore the default. `mode` accepts `flash`, +`thinking`, `pro`, or `ultra`; `reasoning_effort` accepts `minimal`, `low`, +`medium`, or `high`; model names are at most 200 characters. Unknown fields and +invalid values return `422`. Missing preferences read as `null`. Separate-field +patches preserve each other's changes, and same-field writes are last-commit-wins. +Storage requires SQLite or PostgreSQL (`503` when unavailable). Browser +notification permission remains device-local and is not changed by this API. + ### Personal Access Tokens Base URL: `/api/v1/auth` diff --git a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md index c69f2ae91..b889c6fef 100644 --- a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md +++ b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md @@ -22,7 +22,9 @@ The empty-DB path keeps using `create_all` because `Base.metadata` is the only a **Rolling forward compatibility**: the local chain is `0018_oauth_identity_pg_partial` → `0019_projects` → `0020_threads_meta_project_id` → `0021_batch_acceptance` → -`0019_thread_incarnations` → `0022_scheduled_occurrence_seq` (current head). +`0019_thread_incarnations` → `0022_scheduled_occurrence_seq` → +`0023_user_preferences` (current head). The preference revision adds a separate +owner/key table with a cascading users foreign key and does not alter users. The incarnation revision deliberately retains the exact id audited by the rollback-floor binary; Alembic orders revisions by `down_revision`, not by the numeric prefix. diff --git a/backend/packages/harness/deerflow/persistence/migrations/versions/0023_user_preferences.py b/backend/packages/harness/deerflow/persistence/migrations/versions/0023_user_preferences.py new file mode 100644 index 000000000..821707273 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/migrations/versions/0023_user_preferences.py @@ -0,0 +1,26 @@ +"""Persist browser-safe user preferences independently by key.""" + +import sqlalchemy as sa +from alembic import op + +revision = "0023_user_preferences" +down_revision = "0022_scheduled_occurrence_seq" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Legacy bootstrap and create_all-based recovery may already have the table. + if sa.inspect(op.get_bind()).has_table("user_preferences"): + return + op.create_table( + "user_preferences", + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), + sa.Column("key", sa.String(40), primary_key=True), + sa.Column("value", sa.JSON(), nullable=True), + ) + + +def downgrade() -> None: + if sa.inspect(op.get_bind()).has_table("user_preferences"): + op.drop_table("user_preferences") diff --git a/backend/packages/harness/deerflow/persistence/user/AGENTS.md b/backend/packages/harness/deerflow/persistence/user/AGENTS.md new file mode 100644 index 000000000..dc5e5e101 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/user/AGENTS.md @@ -0,0 +1,12 @@ +# User persistence + +`user_preferences` stores independent `(user_id, key)` rows in the shared SQL +database. PATCH upserts only supplied keys in one transaction; `null` resets a +field, disjoint edits commute, and same-field writes are last-commit-wins. + +The Gateway's `GET/PATCH /api/v1/auth/preferences` allows only notification +enablement, the default model, conversation mode, and reasoning effort. It +requires a browser session plus `X-Expected-User-Id` matching that session (a +stale-tab guard, never an authorization source); PAT, internal, and auth-disabled +callers are rejected. Never persist arbitrary agent context or credentials +through this API. See `backend/docs/API.md` for the HTTP contract. diff --git a/backend/packages/harness/deerflow/persistence/user/model.py b/backend/packages/harness/deerflow/persistence/user/model.py index 6708e1d28..a918f1f25 100644 --- a/backend/packages/harness/deerflow/persistence/user/model.py +++ b/backend/packages/harness/deerflow/persistence/user/model.py @@ -13,7 +13,7 @@ from __future__ import annotations from datetime import UTC, datetime -from sqlalchemy import Boolean, DateTime, Index, String, text +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, String, text from sqlalchemy.orm import Mapped, mapped_column from deerflow.persistence.base import Base @@ -30,6 +30,15 @@ from deerflow.persistence.base import Base OAUTH_IDENTITY_INDEX_NAME = "idx_users_oauth_identity" +class UserPreferenceRow(Base): + """Independent keys allow concurrent clients to patch disjoint preferences.""" + + __tablename__ = "user_preferences" + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True) + key: Mapped[str] = mapped_column(String(40), primary_key=True) + value: Mapped[object] = mapped_column(JSON, nullable=True) + + class UserRow(Base): __tablename__ = "users" diff --git a/backend/packages/harness/deerflow/persistence/user/preferences.py b/backend/packages/harness/deerflow/persistence/user/preferences.py new file mode 100644 index 000000000..9a43b8884 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/user/preferences.py @@ -0,0 +1,25 @@ +"""Durable per-user preferences; updates touch only explicitly supplied keys.""" + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from deerflow.persistence.user.model import UserPreferenceRow + + +class UserPreferencesRepository: + def __init__(self, sessions): + self.sessions = sessions + + async def get(self, user_id: str) -> dict: + async with self.sessions() as session: + rows = (await session.execute(select(UserPreferenceRow).where(UserPreferenceRow.user_id == user_id))).scalars() + return {row.key: row.value for row in rows} + + async def patch(self, user_id: str, values: dict) -> None: + async with self.sessions() as session, session.begin(): + insert = pg_insert if session.bind.dialect.name == "postgresql" else sqlite_insert + # Consistent key order also avoids opposite-order row-lock cycles. + for key, value in sorted(values.items()): + statement = insert(UserPreferenceRow).values(user_id=user_id, key=key, value=value) + await session.execute(statement.on_conflict_do_update(index_elements=["user_id", "key"], set_={"value": statement.excluded.value})) diff --git a/backend/tests/test_agent_guidance_check.py b/backend/tests/test_agent_guidance_check.py index 07651b630..0bc9aa1c5 100644 --- a/backend/tests/test_agent_guidance_check.py +++ b/backend/tests/test_agent_guidance_check.py @@ -25,6 +25,7 @@ EXPECTED_GUIDANCE_PATHS = { "backend/packages/harness/deerflow/mcp/AGENTS.md", "backend/packages/harness/deerflow/models/AGENTS.md", "backend/packages/harness/deerflow/persistence/migrations/AGENTS.md", + "backend/packages/harness/deerflow/persistence/user/AGENTS.md", "backend/packages/harness/deerflow/reflection/AGENTS.md", "backend/packages/harness/deerflow/skills/AGENTS.md", "backend/packages/harness/deerflow/subagents/AGENTS.md", diff --git a/backend/tests/test_auth_me_permissions.py b/backend/tests/test_auth_me_permissions.py index d51b2a19d..19d665d5c 100644 --- a/backend/tests/test_auth_me_permissions.py +++ b/backend/tests/test_auth_me_permissions.py @@ -131,6 +131,20 @@ def test_me_lists_all_route_permissions_when_authorization_disabled(client): assert res.json()["permissions"] == _ALL_PERMISSIONS +def test_account_preferences_use_registered_auth_and_csrf_middleware(client): + path = "/api/v1/auth/preferences" + assert client.get(path, headers={"X-Expected-User-Id": "anonymous"}).status_code == 401 + user = _initialize_admin(client).json() + headers = {"X-Expected-User-Id": user["id"]} + assert client.get(path, headers=headers).status_code == 200 + assert client.patch(path, headers=headers, json={"mode": "pro"}).status_code == 403 + headers["X-CSRF-Token"] = client.cookies.get("csrf_token") + assert client.patch(path, headers=headers, json={"mode": "pro"}).status_code == 204 + assert client.get(path, headers=headers).json()["mode"] == "pro" + headers["X-Expected-User-Id"] = "another-user" + assert client.patch(path, headers=headers, json={"mode": "ultra"}).status_code == 409 + + def test_me_reuses_middleware_resolved_permissions(client, monkeypatch): """One provider decision per registered permission — /me reads the AuthContext AuthMiddleware already stamped instead of re-resolving.""" diff --git a/backend/tests/test_migration_0022_scheduled_occurrence_seq.py b/backend/tests/test_migration_0022_scheduled_occurrence_seq.py index 51e3c0e2a..98be92f92 100644 --- a/backend/tests/test_migration_0022_scheduled_occurrence_seq.py +++ b/backend/tests/test_migration_0022_scheduled_occurrence_seq.py @@ -12,9 +12,10 @@ import pytest import pytest_asyncio import sqlalchemy as sa from alembic import command +from alembic.script import ScriptDirectory from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from deerflow.persistence.bootstrap import _get_alembic_config, _get_head_revision +from deerflow.persistence.bootstrap import _MIGRATIONS_DIR, _get_alembic_config from deerflow.persistence.postgres_schema import build_asyncpg_connect_args from deerflow.persistence.scheduled_task_runs import ScheduledTaskRunRepository @@ -83,8 +84,10 @@ async def _schema(engine): ) -async def test_occurrence_revision_is_single_head(): - assert _get_head_revision() == REVISION +async def test_occurrence_revision_is_in_single_head_chain(): + script = ScriptDirectory(str(_MIGRATIONS_DIR)) + assert len(script.get_heads()) == 1 + assert REVISION in {revision.revision for revision in script.walk_revisions()} async def test_upgrade_preserves_legacy_rows_and_allocates_from_one(migration_database): diff --git a/backend/tests/test_user_preferences.py b/backend/tests/test_user_preferences.py new file mode 100644 index 000000000..5d0eebfdc --- /dev/null +++ b/backend/tests/test_user_preferences.py @@ -0,0 +1,150 @@ +"""Owner isolation and disjoint concurrent preference updates.""" + +import asyncio +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from deerflow.persistence.base import Base +from deerflow.persistence.user.model import UserRow +from deerflow.persistence.user.preferences import UserPreferencesRepository + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +async def preference_repo(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/preferences.db") + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as session: + session.add_all([UserRow(id="alice", email="alice@example.com"), UserRow(id="bob", email="bob@example.com")]) + await session.commit() + yield UserPreferencesRepository(sessions) + await engine.dispose() + + +@pytest.mark.anyio +async def test_preferences_survive_repository_recreation_and_remain_owner_scoped(preference_repo): + repo = preference_repo + assert await repo.get("alice") == {} + await repo.patch("alice", {"model_name": "model-a", "notification_enabled": False}) + assert await UserPreferencesRepository(repo.sessions).get("alice") == {"model_name": "model-a", "notification_enabled": False} + assert await repo.get("bob") == {} + await repo.patch("alice", {"model_name": None}) + assert await repo.get("alice") == {"model_name": None, "notification_enabled": False} + + +@pytest.mark.anyio +async def test_disjoint_updates_do_not_erase_each_other(preference_repo): + await asyncio.gather( + preference_repo.patch("alice", {"model_name": "new-model"}), + preference_repo.patch("alice", {"notification_enabled": False}), + ) + assert await preference_repo.get("alice") == {"model_name": "new-model", "notification_enabled": False} + + +@pytest.fixture +async def api(preference_repo, monkeypatch): + from app.gateway.routers import user_preferences + + app = FastAPI() + app.include_router(user_preferences.router) + identity = SimpleNamespace(id="alice", source="session") + + @app.middleware("http") + async def authenticated_session(request, call_next): + if identity.id: + request.state.user = SimpleNamespace(id=identity.id) + request.state.auth_source = identity.source + return await call_next(request) + + monkeypatch.setattr(user_preferences, "_repository", lambda: preference_repo) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + client.headers["X-Expected-User-Id"] = "alice" + yield client, identity + + +@pytest.mark.anyio +async def test_preferences_api_partial_patch_reset_and_owner_isolation(api): + client, identity = api + path = "/api/v1/auth/preferences" + assert (await client.patch(path, json={"notification_enabled": False, "mode": "pro"})).status_code == 204 + assert (await client.patch(path, json={"model_name": "model-a"})).status_code == 204 + assert (await client.get(path)).json() == {"notification_enabled": False, "model_name": "model-a", "mode": "pro", "reasoning_effort": None} + assert (await client.patch(path, json={"mode": None})).status_code == 204 + identity.id = "bob" + # A stale tab with Alice's expected identity must not write using Bob's cookie. + assert (await client.patch(path, json={"mode": "ultra"})).status_code == 409 + assert (await client.get(path)).status_code == 409 + client.headers["X-Expected-User-Id"] = "bob" + assert (await client.get(path)).json() == {"notification_enabled": None, "model_name": None, "mode": None, "reasoning_effort": None} + identity.id = "alice" + client.headers["X-Expected-User-Id"] = "alice" + assert (await client.get(path)).json()["notification_enabled"] is False + assert (await client.get(path)).json()["mode"] is None + + +@pytest.mark.anyio +@pytest.mark.parametrize("body", [{"notification_enabled": "false"}, {"mode": "invalid"}, {"reasoning_effort": "max"}, {"model_name": "a" * 201}, {"context": {"github_token": "not-a-real-token"}}, {"user_id": "bob"}]) +async def test_preferences_api_rejects_invalid_and_unrelated_fields(api, body): + client, _ = api + assert (await client.patch("/api/v1/auth/preferences", json=body)).status_code == 422 + + +@pytest.mark.anyio +@pytest.mark.parametrize("source", ["pat", "internal", "auth_disabled"]) +async def test_preferences_requires_browser_session(api, source): + client, identity = api + identity.source = source + assert (await client.get("/api/v1/auth/preferences")).status_code == 403 + assert (await client.patch("/api/v1/auth/preferences", json={})).status_code == 403 + + +@pytest.mark.anyio +async def test_preferences_requires_authentication_and_expected_identity(api): + client, identity = api + del client.headers["X-Expected-User-Id"] + assert (await client.get("/api/v1/auth/preferences")).status_code == 422 + client.headers["X-Expected-User-Id"] = "alice" + identity.id = None + assert (await client.get("/api/v1/auth/preferences")).status_code == 401 + + +@pytest.mark.anyio +async def test_preferences_read_discards_only_malformed_fields(api, preference_repo): + client, _ = api + await preference_repo.patch("alice", {"notification_enabled": False, "mode": "invalid", "unknown": "ignored"}) + assert (await client.get("/api/v1/auth/preferences")).json() == {"notification_enabled": False, "model_name": None, "mode": None, "reasoning_effort": None} + + +def test_preferences_migration_preserves_existing_users_and_downgrades(tmp_path): + import importlib + + from alembic.migration import MigrationContext + from alembic.operations import Operations + from sqlalchemy import create_engine, inspect, text + + revision = importlib.import_module("deerflow.persistence.migrations.versions.0023_user_preferences") + engine = create_engine(f"sqlite:///{tmp_path}/migration.db") + with engine.begin() as connection: + UserRow.__table__.create(connection) + connection.execute(UserRow.__table__.insert().values(id="alice", email="alice@example.com")) + with Operations.context(MigrationContext.configure(connection)): + revision.upgrade() + assert "user_preferences" in inspect(connection).get_table_names() + connection.execute(text("INSERT INTO user_preferences (user_id, key, value) VALUES ('alice', 'mode', '\"pro\"')")) + revision.upgrade() + assert connection.execute(text("SELECT value FROM user_preferences WHERE user_id = 'alice'")).scalar() == '"pro"' + revision.downgrade() + revision.downgrade() + assert "user_preferences" not in inspect(connection).get_table_names() + assert connection.execute(text("SELECT email FROM users WHERE id = 'alice'")).scalar() == "alice@example.com" + engine.dispose() diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index dbdcf3247..c1b1a75bf 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -13,7 +13,25 @@ ownership and returns 206/416 through `FileResponse`. 3. `useThreadHistory` loads persisted conversation pages from `GET /api/threads/{id}/messages/page`, preserving the backend's thread-global event `seq`; rendering overlays checkpoint/live copies at their matching canonical identities (a summarized checkpoint may contain a protected early input plus a recent tail). Context-compaction rescue diffs every retained visible identity rather than slicing at the first anchor, and keeps a run-scoped ledger of committed visible messages so replacement updates and repeated rolling checkpoint windows cannot erase an already displayed step. A checkpoint/transient prefix whose canonical position is still behind an unloaded cursor page is woven in before the first shared anchor, not discarded: both the checkpoint and seq-sorted history place it earlier, so that position is known even when the pages between are not. Position authority is seq-first and lives in `core/threads/message-order.ts` (re-exported through `hooks.ts`): every normalized identity tracks latest visible content and trusted position separately; a valid `deerflow_seq` (positive safe integer, earliest value wins per identity, hidden control copies contribute a position only when no visible copy carries one) joins an ascending skeleton that outranks identity-anchor weaving, which remains the fallback for no-seq segments whose internal order is preserved. Live-only and rescued messages with trusted seq also serve as anchors for adjacent no-seq segments. A trailing segment follows its last live-only positioned anchor within the loaded window; after a shared anchor or a rescued prefix before the window, it stays at the tail. Optimistic messages remain last; the transient bridge inserts positioned rows before weaving so the insertion cannot reverse previously displayed steps. Rescued sequence anchors preserve preceding captured steps even before React has rendered them; only a leading prefix anchored to loaded history requires previously rendered ordering to cross an unloaded cursor gap. Content replacement never drops the known seq, `run_id`, or `turn_duration`, and the compaction transient bridge plus rendered ledger share the same position priority. `deerflow_seq` is server-owned display metadata and is never written back into a checkpoint. It must never be appended to the tail (#4065) — the tail is provably wrong — but suppressing it entirely is how a user's own question vanished from a long thread once the first 50-row history page no longer reached back to it (#4666). A collapsed unloaded gap is recoverable by paging; a dropped message is not. Weaving alone restores the message but not its exact position — after compaction the live window carries too few anchors — so both sides now carry the backend's thread-global `additional_kwargs.deerflow_seq`: `buildVisibleHistoryMessages` copies each row's `seq`, and the Gateway stamps it onto `values` frame messages it has already persisted. A live message whose seq is below the loaded window's lower bound is placed ahead of everything on screen instead of before the nearest anchor, which is what puts a compaction-rescued first user turn back at the head rather than mid-transcript. That split happens _before_ the anchor walk, not inside it: a compacted checkpoint can share no identity at all with the loaded page — it keeps only the current run's recent tail, while the page on screen was fetched turns earlier — and the anchor walk then never runs at all, which is precisely when a rescued turn most needs its seq. Doing the split inside the walk left that case appending the message after the whole window (#4666), the one arrangement #4065 proved wrong. A message without a seq (still streaming, so not in the feed yet) keeps the weaving path — the tail is already its correct position. Optimistic messages are then added without timestamp re-sorting. History invalidation preserves already-loaded pages so their established ordering positions are not discarded. Dynamic context re-keys each submitted user message from the client-generated `local-human-*` identity `X` to the visible server echo `X__user`; UI identity matching normalizes that reserved suffix only for human messages so the optimistic input and checkpoint replacement remain one visible turn. At dispatch, a local-turn anchor snapshots the checkpoint identity baseline, canonical history identities and maximum trusted seq, and any pre-existing transient-bridge identities. Render repair uses `confirmedHistoryIdentities` plus `preSubmitMaxSeq` to restore baseline or history-confirmed messages above that exact human anchor, while moving only speculative non-baseline AI/tool steps behind it; `currentTurnRunIds` keeps already-persisted steps of the active turn below their human. Keep the anchor scoped to its originating thread through finish, stop, and stream error because the SDK's settled frame can retain transient event order; replace it on the next local submit and clear it on thread switch or replay-gap recovery. 4. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, thread-history, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits -5. TanStack Query manages server state; localStorage stores user settings. The +5. TanStack Query manages server state. `UserPreferencesBoundary` preserves + workspace SSR and initializes the account cache before browser paint; only + subsequent account switches gate consumers until the new cache is active. Four allowlisted + settings sync through `/api/v1/auth/preferences`; confirmed caches are + account-scoped in localStorage and pending patches are account-scoped in + tab-local sessionStorage. A stopped account cannot apply late responses, and + each request carries its expected user ID to fence shared-cookie switches. + Storage events refresh from the server without echo-writing unchanged data; + failed reads and writes retry with capped backoff and focus/online wakeups. + Legacy unscoped settings are never automatically uploaded. Display settings + and thread model overrides remain local. Shared local-only fields still merge + across tabs on storage changes/removal/clear, preserving account preferences. + Explicit InputBox selections pass only fields changed by the action, so a + mode/effort edit cannot upload an unrelated thread model override. + InputBox marks automatic model/mode + resolution separately from user choices on both normal and Custom Agent chat + pages; `resolveThreadContext` must neither + enqueue account writes nor create a fallback thread override that masks a + later server preference. The Settings > Tools MCP switch calls the targeted `PATCH /api/mcp/config` mutation, disables switches until that mutation's success refetch completes, displays the backend error `detail` through a toast, and invalidates diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 0f940b53b..72106e034 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -50,6 +50,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; import { useLocalSettings, useThreadSettings } from "@/core/settings"; +import { resolveThreadContext } from "@/core/settings/store"; import { useThreadMetadata, useThreadStream, @@ -457,9 +458,11 @@ export default function AgentChatPage() { isUploading || (!isNewThread && isHistoryLoading) } - onContextChange={(context) => - setSettings("context", context) - } + onContextChange={(context, options) => { + if (options?.automatic) + resolveThreadContext(threadId, context); + else setSettings("context", context); + }} onGoalChange={setLocalGoal} onSubmit={handleSubmit} onStop={handleStop} diff --git a/frontend/src/app/workspace/workspace-content.tsx b/frontend/src/app/workspace/workspace-content.tsx index 8d14d5763..015e5c801 100644 --- a/frontend/src/app/workspace/workspace-content.tsx +++ b/frontend/src/app/workspace/workspace-content.tsx @@ -9,6 +9,7 @@ import { ModelLoadErrorBanner } from "@/components/workspace/model-load-error-ba import { SettingsDialogHost } from "@/components/workspace/settings"; import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; +import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary"; function parseSidebarOpenCookie( value: string | undefined, @@ -32,18 +33,20 @@ export async function WorkspaceContent({ return ( - - - - - - {children} - - - - - - + + + + + + + {children} + + + + + + + ); } diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx index fd6b1895d..da11636a8 100644 --- a/frontend/src/components/workspace/chats/chat-page.tsx +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -51,6 +51,7 @@ import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; import { useProject } from "@/core/projects"; import { useLocalSettings, useThreadSettings } from "@/core/settings"; +import { resolveThreadContext } from "@/core/settings/store"; import { createThread } from "@/core/threads/api"; import { useBranchThread, @@ -546,9 +547,11 @@ export default function ChatPage() { isUploading || (!isNewThread && isHistoryLoading) } - onContextChange={(context) => - setSettings("context", context) - } + onContextChange={(context, options) => { + if (options?.automatic) + resolveThreadContext(threadId, context); + else setSettings("context", context); + }} onGoalChange={setLocalGoal} onPrepareThread={ensureProjectThread} onSubmit={handleSubmit} diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index cf1e9ff31..005abba8a 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -332,13 +332,18 @@ export function InputBox({ defaultModelName?: string | null; initialValue?: string; onContextChange?: ( - context: Omit< - AgentThreadContext, - "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" - > & { - mode: "flash" | "thinking" | "pro" | "ultra" | undefined; - reasoning_effort?: "minimal" | "low" | "medium" | "high"; - }, + // Explicit selections contain only the fields changed by that action, + // never the whole thread-resolved context (which may override the account). + context: Partial< + Omit< + AgentThreadContext, + "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" + > & { + mode: "flash" | "thinking" | "pro" | "ultra" | undefined; + reasoning_effort?: "minimal" | "low" | "medium" | "high"; + } + >, + options?: { automatic: boolean }, ) => void; onFollowupsVisibilityChange?: (visible: boolean) => void; onGoalChange?: (goal: GoalState | null) => void; @@ -593,11 +598,14 @@ export function InputBox({ return; } - onContextChange?.({ - ...context, - model_name: nextModelName, - mode: nextMode, - }); + onContextChange?.( + { + ...context, + model_name: nextModelName, + mode: nextMode, + }, + { automatic: true }, + ); }, [context, models, defaultModelName, onContextChange]); const selectedModel = useMemo(() => { @@ -850,11 +858,13 @@ export function InputBox({ if (!model) { return; } + const mode = getResolvedMode( + context.mode, + model.supports_thinking ?? false, + ); onContextChange?.({ - ...context, model_name, - mode: getResolvedMode(context.mode, model.supports_thinking ?? false), - reasoning_effort: context.reasoning_effort, + ...(mode !== context.mode ? { mode } : {}), }); setModelDialogOpen(false); }, @@ -867,7 +877,6 @@ export function InputBox({ return; } onContextChange?.({ - ...context, mode: getResolvedMode(mode, supportThinking), reasoning_effort: mode === "ultra" @@ -879,7 +888,7 @@ export function InputBox({ : "minimal", }); }, - [disabled, onContextChange, context, polishingInput, supportThinking], + [disabled, onContextChange, polishingInput, supportThinking], ); const handleReasoningEffortSelect = useCallback( @@ -888,11 +897,10 @@ export function InputBox({ return; } onContextChange?.({ - ...context, reasoning_effort: effort, }); }, - [disabled, onContextChange, context, polishingInput], + [disabled, onContextChange, polishingInput], ); const handleGoalCommand = useCallback( @@ -1138,14 +1146,17 @@ export function InputBox({ // Guard against submitting before the initial model auto-selection // effect has flushed thread settings to storage/state. if (resolvedModelName && context.model_name !== resolvedModelName) { - onContextChange?.({ - ...context, - model_name: resolvedModelName, - mode: getResolvedMode( - context.mode, - selectedModel?.supports_thinking ?? false, - ), - }); + onContextChange?.( + { + ...context, + model_name: resolvedModelName, + mode: getResolvedMode( + context.mode, + selectedModel?.supports_thinking ?? false, + ), + }, + { automatic: true }, + ); return new Promise((resolve, reject) => { setTimeout(() => { Promise.resolve(submit()).then(resolve).catch(reject); diff --git a/frontend/src/core/settings/preferences-sync.ts b/frontend/src/core/settings/preferences-sync.ts new file mode 100644 index 000000000..e3a41b926 --- /dev/null +++ b/frontend/src/core/settings/preferences-sync.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +const fields = { + notification_enabled: z.boolean().nullable(), + model_name: z.string().max(200).nullable(), + mode: z.enum(["flash", "thinking", "pro", "ultra"]).nullable(), + reasoning_effort: z.enum(["minimal", "low", "medium", "high"]).nullable(), +}; +const schema = z.object(fields).partial(); +export type Preferences = z.infer; + +/** Keep valid individual preferences; never copy arbitrary runtime context. */ +export function parsePreferences(value: unknown): Preferences { + if (!value || typeof value !== "object") return {}; + const valid: Record = {}; + for (const [key, field] of Object.entries(fields)) { + const parsed = field.safeParse((value as Record)[key]); + if (parsed.success) valid[key] = parsed.data; + } + return schema.parse(valid); +} + +interface SyncIO { + read: () => Promise; + patch: (value: Preferences) => Promise; + apply: (value: Preferences) => void; + savePending: (value: Preferences) => void; + saveConfirmed?: (value: Preferences) => void; +} + +/** One instance belongs to one account and one tab, including its outbox. */ +export class PreferencesSync { + private stopped = false; + private running?: Promise; + + constructor( + private readonly io: SyncIO, + private confirmed: Preferences, + private pending: Preferences, + ) { + this.publish(); + } + + private publish() { + this.io.apply({ ...this.confirmed, ...this.pending }); + } + + edit(value: Preferences) { + if (this.stopped) return; + this.pending = { ...this.pending, ...value }; + this.io.savePending(this.pending); + this.publish(); + } + + stop() { + this.stopped = true; + } + + flush(): Promise { + if (this.stopped) return Promise.resolve(); + this.running ??= this.run().finally(() => { + this.running = undefined; + }); + return this.running; + } + + private async run() { + const remote = await this.io.read(); + if (this.stopped) return; + this.confirmed = remote; + this.io.saveConfirmed?.(this.confirmed); + this.publish(); + while (!this.stopped && Object.keys(this.pending).length) { + const sent = { ...this.pending }; + await this.io.patch(sent); + if (this.stopped) return; + this.confirmed = { ...this.confirmed, ...sent }; + this.io.saveConfirmed?.(this.confirmed); + for (const key of Object.keys(sent) as (keyof Preferences)[]) { + if (this.pending[key] === sent[key]) delete this.pending[key]; + } + this.io.savePending(this.pending); + this.publish(); + } + } +} diff --git a/frontend/src/core/settings/store.ts b/frontend/src/core/settings/store.ts index 32e7f96ce..faa4931b5 100644 --- a/frontend/src/core/settings/store.ts +++ b/frontend/src/core/settings/store.ts @@ -8,6 +8,7 @@ import { saveThreadModelName, type LocalSettings, } from "./local"; +import type { Preferences } from "./preferences-sync"; type Listener = () => void; @@ -22,6 +23,42 @@ const threadModelNames = new Map(); let baseSettings: LocalSettings = DEFAULT_LOCAL_SETTINGS; let baseSettingsLoaded = false; let storageListenerRegistered = false; +let preferenceEdit: + | ((before: LocalSettings, after: LocalSettings) => void) + | undefined; + +/** Activate only after the workspace has identified the account. */ +export function activatePreferences( + initial: Preferences, + edit: (before: LocalSettings, after: LocalSettings) => void, +) { + ensureBaseSettingsLoaded(); + preferenceEdit = edit; + const apply = (value: Preferences) => { + if (preferenceEdit !== edit) return; + baseSettings = { + ...baseSettings, + notification: { enabled: value.notification_enabled ?? true }, + context: { + ...baseSettings.context, + model_name: value.model_name ?? undefined, + mode: value.mode ?? undefined, + reasoning_effort: value.reasoning_effort ?? undefined, + }, + }; + emitChange(); + }; + apply(initial); + return { + apply, + stop: () => { + if (preferenceEdit === edit) { + apply({}); + preferenceEdit = undefined; + } + }, + }; +} function emitChange() { for (const listener of listeners) { @@ -29,6 +66,25 @@ function emitChange() { } } +function persistLocalOnlySettings() { + if (!preferenceEdit) { + saveLocalSettings(baseSettings); + return; + } + // Account preferences never leak back into the legacy shared-origin key. + const legacy = getLocalSettings(); + saveLocalSettings({ + ...baseSettings, + notification: legacy.notification, + context: { + ...baseSettings.context, + model_name: legacy.context.model_name, + mode: legacy.context.mode, + reasoning_effort: legacy.context.reasoning_effort, + }, + }); +} + function ensureBaseSettingsLoaded() { if (baseSettingsLoaded || typeof window === "undefined") { return; @@ -73,6 +129,23 @@ function mergeSettingsSection( }; } +function readSharedSettings(): LocalSettings { + const local = getLocalSettings(); + if (!preferenceEdit) return local; + // Device-local fields still follow other tabs, including key removal and + // storage.clear(). The legacy key must never replace account preferences. + return { + ...local, + notification: baseSettings.notification, + context: { + ...local.context, + model_name: baseSettings.context.model_name, + mode: baseSettings.context.mode, + reasoning_effort: baseSettings.context.reasoning_effort, + }, + }; +} + function handleStorage(event: StorageEvent) { if (event.storageArea && event.storageArea !== localStorage) { return; @@ -81,14 +154,14 @@ function handleStorage(event: StorageEvent) { ensureBaseSettingsLoaded(); if (event.key === null) { - baseSettings = getLocalSettings(); + baseSettings = readSharedSettings(); threadModelNames.clear(); emitChange(); return; } if (event.key === LOCAL_SETTINGS_KEY) { - baseSettings = getLocalSettings(); + baseSettings = readSharedSettings(); emitChange(); return; } @@ -131,8 +204,10 @@ export const updateLocalSettings: LocalSettingsSetter = (key, value) => { ensureBaseSettingsLoaded(); ensureStorageListenerRegistered(); + const previous = baseSettings; baseSettings = mergeSettingsSection(baseSettings, key, value); - saveLocalSettings(baseSettings); + persistLocalOnlySettings(); + preferenceEdit?.(previous, baseSettings); emitChange(); }; @@ -144,9 +219,11 @@ export function updateThreadSettings( ensureBaseSettingsLoaded(); ensureStorageListenerRegistered(); + const previous = baseSettings; const nextBaseSettings = mergeSettingsSection(baseSettings, key, value); baseSettings = nextBaseSettings; - saveLocalSettings(baseSettings); + persistLocalOnlySettings(); + preferenceEdit?.(previous, baseSettings); if ( key === "context" && @@ -160,3 +237,22 @@ export function updateThreadSettings( emitChange(); } + +/** Model availability/default resolution is not an explicit account edit. */ +export function resolveThreadContext( + threadId: string, + context: Partial, +) { + if (!preferenceEdit) { + updateThreadSettings(threadId, "context", context); + return; + } + baseSettings = mergeSettingsSection(baseSettings, "context", context); + // Preserve explicit thread overrides, but do not create one from a temporary + // fallback: it would mask the account model when a slow GET finally arrives. + if (getThreadModelSnapshot(threadId) && "model_name" in context) { + threadModelNames.set(threadId, context.model_name); + saveThreadModelName(threadId, context.model_name); + } + emitChange(); +} diff --git a/frontend/src/core/settings/user-preferences-boundary.tsx b/frontend/src/core/settings/user-preferences-boundary.tsx new file mode 100644 index 000000000..39bd9a6eb --- /dev/null +++ b/frontend/src/core/settings/user-preferences-boundary.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect, useLayoutEffect, useState, type ReactNode } from "react"; + +import { useAuth } from "@/core/auth/AuthProvider"; +import { isStaticWebsiteOnly } from "@/core/static-mode"; + +import { startUserPreferences } from "./user-preferences"; + +const useClientLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +export function UserPreferencesBoundary({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const owner = + !isStaticWebsiteOnly() && user?.id !== "default" ? user?.id : undefined; + // SSR and initial hydration render the same workspace with the settings + // store's safe server snapshot. Initialize the browser cache before paint. + const [activeOwner, setActiveOwner] = useState(owner); + useClientLayoutEffect(() => { + const stop = owner ? startUserPreferences(owner) : undefined; + setActiveOwner(owner); + return stop; + }, [owner]); + // Only gate a subsequent account switch, keeping the old owner's settings + // out of the new owner's consumers until the new cache is activated. + if (owner !== activeOwner) return null; + return children; +} diff --git a/frontend/src/core/settings/user-preferences.ts b/frontend/src/core/settings/user-preferences.ts new file mode 100644 index 000000000..8f9a68961 --- /dev/null +++ b/frontend/src/core/settings/user-preferences.ts @@ -0,0 +1,143 @@ +import { fetch as apiFetch } from "@/core/api/fetcher"; +import { getBackendBaseURL } from "@/core/config"; + +import { safeLocalStorage, type LocalSettings } from "./local"; +import { + parsePreferences, + PreferencesSync, + type Preferences, +} from "./preferences-sync"; +import { activatePreferences } from "./store"; + +const PREFIX = "deerflow.preferences."; + +function readJSON(read: () => string | null): Preferences { + try { + return parsePreferences(JSON.parse(read() ?? "{}")); + } catch { + return {}; + } +} + +export function preferencesFromSettings(settings: LocalSettings): Preferences { + return { + notification_enabled: settings.notification.enabled, + model_name: settings.context.model_name ?? null, + mode: settings.context.mode ?? null, + reasoning_effort: settings.context.reasoning_effort ?? null, + }; +} + +/** Network lifecycle is attached to the authenticated workspace, never render. */ +export function startUserPreferences(userId: string) { + const key = `${PREFIX}${userId}`; + // sessionStorage is tab-local; a sibling tab cannot overwrite our outbox. + const outbox = `${key}.pending`; + const abort = new AbortController(); + let stopped = false; + let timer: ReturnType | undefined; + let retryMs = 1000; + let flushing = false; + const request = async (method: "GET" | "PATCH", value?: Preferences) => { + const response = await apiFetch( + `${getBackendBaseURL()}/api/v1/auth/preferences`, + { + method, + headers: { + "Content-Type": "application/json", + "X-Expected-User-Id": userId, + }, + body: value ? JSON.stringify(value) : undefined, + signal: AbortSignal.any([abort.signal, AbortSignal.timeout(15000)]), + cache: "no-store", + }, + ); + if (response.status === 409 || response.status === 403) { + // A cookie can switch accounts in another tab before useAuth updates. + // Keep this account's outbox, but never retry it under the new identity. + stop(); + } + if (!response.ok) + throw new Error(`Preferences request failed: ${response.status}`); + return response; + }; + let apply: (value: Preferences) => void = () => undefined; + const cached = readJSON(() => safeLocalStorage.getItem(key)); + const pending = readJSON(() => window.sessionStorage.getItem(outbox)); + const sync = new PreferencesSync( + { + read: async () => parsePreferences(await (await request("GET")).json()), + patch: async (value) => { + await request("PATCH", value); + }, + saveConfirmed: (value) => { + const serialized = JSON.stringify(value); + if (safeLocalStorage.getItem(key) !== serialized) + safeLocalStorage.setItem(key, serialized); + }, + apply: (value) => apply(value), + savePending: (value) => { + try { + window.sessionStorage.setItem(outbox, JSON.stringify(value)); + } catch {} + }, + }, + cached, + pending, + ); + + const wake = () => { + if (stopped || flushing) return; + clearTimeout(timer); + flushing = true; + void sync + .flush() + .then(() => { + retryMs = 1000; + }) + .catch(() => { + if (stopped) return; + timer = setTimeout(wake, retryMs); + retryMs = Math.min(retryMs * 2, 30000); + }) + .finally(() => { + flushing = false; + }); + }; + const detach = activatePreferences( + { ...cached, ...pending }, + (before, after) => { + const oldValue = preferencesFromSettings(before); + const newValue = preferencesFromSettings(after); + const changes = Object.fromEntries( + Object.entries(newValue).filter( + ([field, value]) => oldValue[field as keyof Preferences] !== value, + ), + ); + if (Object.keys(changes).length) { + sync.edit(parsePreferences(changes)); + wake(); + } + }, + ); + apply = detach.apply; + const onStorage = (event: StorageEvent) => { + if (event.key === key || event.key === null) wake(); + }; + function stop() { + if (stopped) return; + stopped = true; + clearTimeout(timer); + abort.abort(); + sync.stop(); + detach.stop(); + window.removeEventListener("focus", wake); + window.removeEventListener("online", wake); + window.removeEventListener("storage", onStorage); + } + window.addEventListener("focus", wake); + window.addEventListener("online", wake); + window.addEventListener("storage", onStorage); + wake(); + return stop; +} diff --git a/frontend/tests/e2e/settings-model-sync.spec.ts b/frontend/tests/e2e/settings-model-sync.spec.ts new file mode 100644 index 000000000..4f415abec --- /dev/null +++ b/frontend/tests/e2e/settings-model-sync.spec.ts @@ -0,0 +1,294 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api"; + +for (const agent of [false, true]) { + for (const field of ["effort", "mode"] as const) { + test(`${agent ? "agent" : "normal"} thread ${field} selection preserves the account model`, async ({ + page, + }) => { + mockLangGraphAPI(page, { + agents: [{ name: "researcher", description: "Research agent" }], + threads: [ + { + thread_id: MOCK_THREAD_ID, + agent_name: agent ? "researcher" : undefined, + }, + ], + }); + await page.addInitScript( + ({ threadId }) => { + localStorage.setItem( + `deerflow.thread-model.${threadId}`, + "thread-model", + ); + }, + { threadId: MOCK_THREAD_ID }, + ); + await page.route("**/api/models", (route) => + route.fulfill({ + json: { + models: [ + { + name: "account-model", + display_name: "Account Model", + supports_thinking: true, + supports_reasoning_effort: true, + }, + { + name: "thread-model", + display_name: "Thread Model", + supports_thinking: true, + supports_reasoning_effort: true, + }, + ], + }, + }), + ); + await page.route("**/api/v1/auth/me", (route) => + route.fulfill({ + json: { + id: "00000000-0000-0000-0000-000000000028", + email: "thread@example.com", + system_role: "admin", + needs_setup: false, + }, + }), + ); + const patches: unknown[] = []; + let reads = 0; + let server = { + model_name: "account-model", + mode: "pro", + reasoning_effort: "medium", + notification_enabled: true, + }; + await page.route("**/api/v1/auth/preferences", async (route) => { + if (route.request().method() === "PATCH") { + const patch = route.request().postDataJSON() as Partial< + typeof server + >; + patches.push(patch); + server = { ...server, ...patch }; + await route.fulfill({ status: 204 }); + } else { + reads++; + await route.fulfill({ json: server }); + } + }); + await page.goto( + `/workspace/${agent ? "agents/researcher/chats" : "chats"}/${MOCK_THREAD_ID}`, + ); + await page + .locator("[data-sidebar='sidebar']") + .getByRole("button", { name: /Settings and more/ }) + .click(); + await page.keyboard.press("Escape"); + await page.evaluate(() => + document.dispatchEvent(new Event("visibilitychange")), + ); + await expect.poll(() => reads).toBe(1); + await expect( + page.getByRole("button", { name: "Thread Model", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { + name: "Reasoning Effort: Medium", + exact: true, + }), + ).toBeVisible(); + if (field === "effort") { + await page + .getByRole("button", { + name: "Reasoning Effort: Medium", + exact: true, + }) + .click(); + await page.getByRole("menuitem").filter({ hasText: /^High/ }).click(); + } else { + await page.getByRole("button", { name: "Pro", exact: true }).click(); + await page + .getByRole("menuitem") + .filter({ hasText: /^Ultra/ }) + .click(); + } + await expect + .poll(() => patches) + .toEqual([ + field === "effort" + ? { reasoning_effort: "high" } + : { mode: "ultra", reasoning_effort: "high" }, + ]); + expect(server.model_name).toBe("account-model"); + await page.keyboard.press("Escape"); + await expect( + page.getByRole("button", { name: "Thread Model", exact: true }), + ).toBeVisible(); + }); + } +} + +test("custom agent automatic default does not become an account preference", async ({ + page, +}) => { + mockLangGraphAPI(page, { + agents: [{ name: "researcher", description: "Research agent" }], + }); + const patches: unknown[] = []; + let reads = 0; + let allowModels!: () => void; + const modelsReady = new Promise((resolve) => { + allowModels = resolve; + }); + await page.route("**/api/agents/researcher", (route) => + route.fulfill({ + json: { + name: "researcher", + description: "Research agent", + model: "agent-model", + system_prompt: "Research", + tools: [], + skills: [], + }, + }), + ); + await page.route("**/api/models", async (route) => { + // Do not let the auth-disabled fixture select a model before the account + // is activated. The real agent page must then choose its configured model. + await modelsReady; + await route.fulfill({ + json: { + models: [ + { + name: "first-model", + display_name: "First Model", + supports_thinking: true, + }, + { + name: "agent-model", + display_name: "Agent Model", + supports_thinking: true, + }, + ], + }, + }); + }); + await page.route("**/api/v1/auth/me", (route) => + route.fulfill({ + json: { + id: "00000000-0000-0000-0000-000000000027", + email: "agent@example.com", + system_role: "admin", + needs_setup: false, + }, + }), + ); + await page.route("**/api/v1/auth/preferences", async (route) => { + if (route.request().method() === "PATCH") { + patches.push(route.request().postDataJSON()); + await route.fulfill({ status: 204 }); + } else { + reads++; + await route.fulfill({ + json: { + model_name: null, + mode: null, + reasoning_effort: null, + notification_enabled: true, + }, + }); + } + }); + await page.goto("/workspace/agents/researcher/chats/new"); + await page + .locator("[data-sidebar='sidebar']") + .getByRole("button", { name: /Settings and more/ }) + .click(); + await page.keyboard.press("Escape"); + await page.evaluate(() => + document.dispatchEvent(new Event("visibilitychange")), + ); + await expect.poll(() => reads).toBe(1); + allowModels(); + await expect( + page.getByRole("button", { name: "Agent Model", exact: true }), + ).toBeVisible(); + // Flush the local outbox cycle through a completed server read, so this also + // catches a PATCH that would otherwise arrive just after the UI assertion. + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + await expect.poll(() => reads).toBe(2); + expect(patches).toEqual([]); + // Explicit model selections on this same page must still be synchronized. + await page.getByRole("button", { name: "Agent Model", exact: true }).click(); + await page.getByRole("option").filter({ hasText: "First Model" }).click(); + await expect.poll(() => patches).toEqual([{ model_name: "first-model" }]); +}); + +test("automatic model fallback cannot overwrite a slowly loaded account preference", async ({ + page, +}) => { + mockLangGraphAPI(page); + const patches: unknown[] = []; + let requested = false; + let release!: () => void; + const delayed = new Promise((resolve) => { + release = resolve; + }); + await page.route("**/api/models", (route) => + route.fulfill({ + json: { + models: [ + { name: "model-a", display_name: "Model A", supports_thinking: true }, + { name: "model-b", display_name: "Model B", supports_thinking: true }, + ], + }, + }), + ); + await page.route("**/api/v1/auth/me", (route) => + route.fulfill({ + json: { + id: "00000000-0000-0000-0000-000000000026", + email: "model@example.com", + system_role: "admin", + needs_setup: false, + }, + }), + ); + await page.route("**/api/v1/auth/preferences", async (route) => { + if (route.request().method() === "PATCH") { + patches.push(route.request().postDataJSON()); + await route.fulfill({ status: 204 }); + return; + } + requested = true; + await delayed; + await route.fulfill({ + json: { + model_name: "model-b", + mode: "pro", + reasoning_effort: "high", + notification_enabled: true, + }, + }); + }); + await page.goto("/workspace/chats/new"); + // Wait for actual hydration before refreshing the auth-disabled fixture's + // AuthProvider into a session account with a deliberately slow preference GET. + await page + .locator("[data-sidebar='sidebar']") + .getByRole("button", { name: /Settings and more/ }) + .click(); + await page.keyboard.press("Escape"); + await page.evaluate(() => + document.dispatchEvent(new Event("visibilitychange")), + ); + await expect.poll(() => requested).toBe(true); + await expect( + page.getByRole("button", { name: "Model A", exact: true }), + ).toBeVisible(); + release(); + await expect( + page.getByRole("button", { name: "Model B", exact: true }), + ).toBeVisible(); + expect(patches).toEqual([]); +}); diff --git a/frontend/tests/e2e/settings-sync.spec.ts b/frontend/tests/e2e/settings-sync.spec.ts new file mode 100644 index 000000000..66bff8a16 --- /dev/null +++ b/frontend/tests/e2e/settings-sync.spec.ts @@ -0,0 +1,86 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +test("account notification setting survives clearing browser storage", async ({ + page, +}) => { + mockLangGraphAPI(page); + const owner = "00000000-0000-0000-0000-000000000025"; + let enabled: boolean | null = null; + const patches: unknown[] = []; + await page.route("**/api/v1/auth/me", (route) => + route.fulfill({ + json: { + id: owner, + email: "preferences@example.com", + system_role: "admin", + needs_setup: false, + }, + }), + ); + await page.route("**/api/v1/auth/preferences", async (route) => { + expect(route.request().headers()["x-expected-user-id"]).toBe(owner); + if (route.request().method() === "PATCH") { + const patch = route.request().postDataJSON() as { + notification_enabled: boolean; + }; + patches.push(patch); + enabled = patch.notification_enabled; + await route.fulfill({ status: 204 }); + } else { + await route.fulfill({ + json: { + notification_enabled: enabled, + model_name: null, + mode: null, + reasoning_effort: null, + }, + }); + } + }); + await page.addInitScript(() => { + Object.defineProperty(Notification, "permission", { + configurable: true, + get: () => "granted", + }); + }); + + async function openSettings(page: Page) { + await page.goto("/workspace/chats/new"); + await page + .locator("[data-sidebar='sidebar']") + .getByRole("button", { name: /Settings and more/ }) + .click(); + await page.getByRole("menuitem", { name: "Settings", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Settings", exact: true }); + await dialog + .getByRole("button", { name: "Notification", exact: true }) + .click(); + // Opening the dialog also waits for hydration. The mock web server starts + // auth-disabled; refresh the real AuthProvider to this session fixture. + const hydrated = page.waitForResponse((response) => + response.url().endsWith("/api/v1/auth/preferences"), + ); + await page.evaluate(() => + document.dispatchEvent(new Event("visibilitychange")), + ); + await hydrated; + // Account resolution remounts the dialog at its default section. + await dialog + .getByRole("button", { name: "Notification", exact: true }) + .click(); + return dialog.getByRole("switch", { name: "Notification", exact: true }); + } + + const toggle = await openSettings(page); + await expect(toggle).toBeChecked(); + await toggle.click(); + await expect.poll(() => enabled).toBe(false); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + await expect(await openSettings(page)).not.toBeChecked(); + expect(patches).toEqual([{ notification_enabled: false }]); +}); diff --git a/frontend/tests/unit/core/settings/preferences-sync.test.ts b/frontend/tests/unit/core/settings/preferences-sync.test.ts new file mode 100644 index 000000000..d6e3158ef --- /dev/null +++ b/frontend/tests/unit/core/settings/preferences-sync.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@rstest/core"; + +import { + PreferencesSync, + type Preferences, +} from "@/core/settings/preferences-sync"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function setup( + read: () => Promise, + patch: (value: Preferences) => Promise, +) { + let visible: Preferences = {}; + let pending: Preferences = {}; + const sync = new PreferencesSync( + { + read, + patch, + apply: (value) => { + visible = value; + }, + savePending: (value) => { + pending = value; + }, + }, + {}, + {}, + ); + return { sync, visible: () => visible, pending: () => pending }; +} + +describe("account preference synchronization", () => { + it("retries a failed bootstrap and preserves edits made while loading", async () => { + let attempts = 0; + const writes: Preferences[] = []; + const state = setup( + async () => { + if (++attempts === 1) throw new Error("offline"); + return { mode: "pro", notification_enabled: true }; + }, + async (value) => { + writes.push(value); + }, + ); + await expect(state.sync.flush()).rejects.toThrow("offline"); + state.sync.edit({ notification_enabled: false }); + await state.sync.flush(); + expect(writes).toEqual([{ notification_enabled: false }]); + expect(state.visible()).toEqual({ + mode: "pro", + notification_enabled: false, + }); + expect(state.pending()).toEqual({}); + }); + + it("does not let an older acknowledgement erase a newer edit", async () => { + const ack = deferred(); + const writes: Preferences[] = []; + const state = setup( + async () => ({}), + async (value) => { + writes.push(value); + if (writes.length === 1) await ack.promise; + }, + ); + await state.sync.flush(); + state.sync.edit({ mode: "pro" }); + const flushing = state.sync.flush(); + await Promise.resolve(); + state.sync.edit({ mode: "flash" }); + ack.resolve(); + await flushing; + expect(writes).toEqual([{ mode: "pro" }, { mode: "flash" }]); + expect(state.visible().mode).toBe("flash"); + expect(state.pending()).toEqual({}); + }); + + it("ignores a late read after stopping an account", async () => { + const read = deferred(); + const state = setup( + () => read.promise, + async () => undefined, + ); + const loading = state.sync.flush(); + state.sync.stop(); + read.resolve({ model_name: "alice-only" }); + await loading; + expect(state.visible()).toEqual({}); + }); +}); diff --git a/frontend/tests/unit/core/settings/user-preferences-ssr.test.tsx b/frontend/tests/unit/core/settings/user-preferences-ssr.test.tsx new file mode 100644 index 000000000..c774be395 --- /dev/null +++ b/frontend/tests/unit/core/settings/user-preferences-ssr.test.tsx @@ -0,0 +1,35 @@ +import { expect, rs, test } from "@rstest/core"; +import { renderToString } from "react-dom/server"; + +import { useLocalSettings } from "@/core/settings/hooks"; +import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary"; + +const mocks = rs.hoisted(() => ({ start: rs.fn() })); +rs.mock("@/core/auth/AuthProvider", () => ({ + useAuth: () => ({ user: { id: "alice" } }), +})); +rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false })); +rs.mock("@/core/settings/user-preferences", () => ({ + startUserPreferences: mocks.start, +})); + +function Workspace() { + const [settings] = useLocalSettings(); + return ( +
+ + {settings.context.model_name ?? "default"} +
+ ); +} + +test("signed-in workspace content remains in server HTML without starting browser sync", () => { + const html = renderToString( + + + , + ); + expect(html).toContain(""); + expect(html).toContain("default"); + expect(mocks.start).not.toHaveBeenCalled(); +}); diff --git a/frontend/tests/unit/core/settings/user-preferences.dom.test.tsx b/frontend/tests/unit/core/settings/user-preferences.dom.test.tsx new file mode 100644 index 000000000..98d9f6129 --- /dev/null +++ b/frontend/tests/unit/core/settings/user-preferences.dom.test.tsx @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { useSyncExternalStore } from "react"; +import { hydrateRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; + +import { + DEFAULT_LOCAL_SETTINGS, + getLocalSettings, + LOCAL_SETTINGS_KEY, +} from "@/core/settings/local"; +import { + getBaseSettingsSnapshot, + getThreadModelSnapshot, + resolveThreadContext, + subscribe, + updateLocalSettings, +} from "@/core/settings/store"; +import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary"; + +const mocks = rs.hoisted(() => ({ + user: { id: "alice" }, + fetch: rs.fn(), +})); +rs.mock("@/core/auth/AuthProvider", () => ({ + useAuth: () => ({ user: mocks.user }), +})); +rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" })); +rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false })); +rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch })); + +function Consumer() { + const settings = useSyncExternalStore( + subscribe, + getBaseSettingsSnapshot, + () => DEFAULT_LOCAL_SETTINGS, + ); + return ( + {`${settings.notification.enabled}:${settings.context.model_name ?? "default"}:${settings.context.mode ?? "default"}`} + ); +} +function Workspace() { + return ( + + + + ); +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + mocks.user = { id: "alice" }; + mocks.fetch.mockReset(); +}); +afterEach(() => { + cleanup(); +}); + +describe("authenticated workspace preferences", () => { + it.each(["write", "remove", "clear"] as const)( + "merges local-only settings on a cross-tab %s without changing account preferences", + async (operation) => { + const account = { + notification_enabled: false, + model_name: "account-model", + mode: "pro", + reasoning_effort: "high", + }; + mocks.fetch.mockImplementation(async () => Response.json(account)); + render(); + await screen.findByText("false:account-model:pro"); + act(() => { + updateLocalSettings("projectsDisplayMode", "grouped"); + updateLocalSettings("tokenUsage", { + headerTotal: false, + inlineMode: "off", + }); + }); + act(() => { + if (operation === "write") { + localStorage.setItem( + LOCAL_SETTINGS_KEY, + JSON.stringify({ + projectsDisplayMode: "flat", + tokenUsage: { headerTotal: true, inlineMode: "per_turn" }, + notification: { enabled: true }, + context: { + model_name: "legacy-model", + mode: "flash", + reasoning_effort: "low", + }, + }), + ); + } else if (operation === "remove") { + localStorage.removeItem(LOCAL_SETTINGS_KEY); + } else { + localStorage.clear(); + } + window.dispatchEvent( + new StorageEvent("storage", { + key: operation === "clear" ? null : LOCAL_SETTINGS_KEY, + storageArea: localStorage, + }), + ); + }); + expect(getBaseSettingsSnapshot()).toMatchObject({ + projectsDisplayMode: "flat", + tokenUsage: { headerTotal: true, inlineMode: "per_turn" }, + notification: { enabled: false }, + context: { + model_name: "account-model", + mode: "pro", + reasoning_effort: "high", + }, + }); + // A later unrelated write must not restore the stale display setting. + act(() => updateLocalSettings("tokenUsage", { headerTotal: false })); + expect(getLocalSettings().projectsDisplayMode).toBe("flat"); + await act(async () => { + await Promise.resolve(); + }); + expect( + mocks.fetch.mock.calls.every(([, init]) => init.method === "GET"), + ).toBe(true); + expect( + sessionStorage.getItem("deerflow.preferences.alice.pending"), + ).toBeNull(); + }, + ); + it("hydrates server HTML from the correct account cache without mismatches", async () => { + localStorage.setItem( + LOCAL_SETTINGS_KEY, + JSON.stringify({ context: { model_name: "legacy-other-account" } }), + ); + localStorage.setItem( + "deerflow.preferences.alice", + JSON.stringify({ + notification_enabled: false, + model_name: "alice-cached", + }), + ); + mocks.fetch.mockImplementation( + () => new Promise(() => undefined), + ); + const container = document.createElement("div"); + container.innerHTML = renderToString(); + expect(container.textContent).toBe("true:default:default"); + document.body.appendChild(container); + const errors: unknown[] = []; + let root!: Root; + try { + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError: (error) => { + errors.push(error); + }, + }); + }); + expect(container.textContent).toBe("false:alice-cached:default"); + expect(errors).toEqual([]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); + it("does not upload automatic model fallback or mask a later server model", async () => { + let finish!: (response: Response) => void; + mocks.fetch.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render(); + await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1)); + act(() => + resolveThreadContext("slow-hydration", { + model_name: "fallback", + mode: "pro", + }), + ); + expect( + sessionStorage.getItem("deerflow.preferences.alice.pending"), + ).toBeNull(); + expect(getThreadModelSnapshot("slow-hydration")).toBeUndefined(); + finish(Response.json({ model_name: "server-choice", mode: "ultra" })); + await screen.findByText("true:server-choice:ultra"); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + }); + it("restores server preferences after clearing browser storage and sends only edited fields", async () => { + let server = { + notification_enabled: false, + model_name: "saved-model", + mode: "pro", + }; + mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => { + expect(new Headers(init.headers).get("X-Expected-User-Id")).toBe("alice"); + if (init.method === "PATCH") { + expect(JSON.parse(init.body as string)).toEqual({ + notification_enabled: true, + }); + server = { ...server, ...JSON.parse(init.body as string) }; + return new Response(null, { status: 204 }); + } + return Response.json(server); + }); + const view = render(); + await screen.findByText("false:saved-model:pro"); + act(() => updateLocalSettings("notification", { enabled: true })); + await waitFor(() => expect(server.notification_enabled).toBe(true)); + await waitFor(() => + expect(sessionStorage.getItem("deerflow.preferences.alice.pending")).toBe( + "{}", + ), + ); + view.unmount(); + localStorage.clear(); + render(); + await screen.findByText("true:saved-model:pro"); + }); + + it("does not import legacy preferences or expose the previous account on a switch", async () => { + localStorage.setItem( + LOCAL_SETTINGS_KEY, + JSON.stringify({ + notification: { enabled: false }, + context: { model_name: "legacy-secret" }, + }), + ); + mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => { + expect(init.method).toBe("GET"); + const owner = new Headers(init.headers).get("X-Expected-User-Id"); + return Response.json( + owner === "alice" ? { model_name: "alice-model" } : {}, + ); + }); + const view = render(); + await screen.findByText("true:alice-model:default"); + mocks.user = { id: "bob" }; + view.rerender(); + expect(screen.queryByText("true:alice-model:default")).toBeNull(); + await screen.findByText("true:default:default"); + expect(localStorage.getItem(LOCAL_SETTINGS_KEY)).toContain("legacy-secret"); + }); + + it("retries failed initialization on reconnect and retains pending changes across reload", async () => { + mocks.fetch.mockRejectedValue(new Error("offline")); + const view = render(); + await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1)); + act(() => updateLocalSettings("context", { mode: "ultra" })); + expect( + JSON.parse(sessionStorage.getItem("deerflow.preferences.alice.pending")!), + ).toEqual({ mode: "ultra" }); + view.unmount(); + mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => { + if (init.method === "PATCH") { + expect(JSON.parse(init.body as string)).toEqual({ mode: "ultra" }); + return new Response(null, { status: 204 }); + } + return Response.json({ model_name: "remote" }); + }); + render(); + act(() => { + window.dispatchEvent(new Event("online")); + }); + await screen.findByText("true:remote:ultra"); + await waitFor(() => + expect(sessionStorage.getItem("deerflow.preferences.alice.pending")).toBe( + "{}", + ), + ); + }); + + it("does not write an unchanged cache back in response to a storage event", async () => { + mocks.fetch.mockResolvedValue(Response.json({ mode: "pro" })); + // Each HTTP request needs its own readable Response body. + mocks.fetch.mockImplementation(async () => Response.json({ mode: "pro" })); + render(); + await screen.findByText("true:default:pro"); + const writes = rs.spyOn(Storage.prototype, "setItem"); + act(() => { + window.dispatchEvent( + new StorageEvent("storage", { key: "deerflow.preferences.alice" }), + ); + }); + await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(2)); + expect(writes).not.toHaveBeenCalled(); + writes.mockRestore(); + }); + + it("stops requests when another tab changes the authenticated account", async () => { + sessionStorage.setItem( + "deerflow.preferences.alice.pending", + JSON.stringify({ mode: "ultra" }), + ); + mocks.fetch.mockImplementation( + async () => new Response(null, { status: 409 }), + ); + render(); + await waitFor(() => expect(mocks.fetch).toHaveBeenCalledTimes(1)); + await act(async () => { + await Promise.resolve(); + }); + act(() => { + window.dispatchEvent(new Event("focus")); + }); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + expect( + sessionStorage.getItem("deerflow.preferences.alice.pending"), + ).toContain("ultra"); + }); +});