Skip to content

refactor(settings): nest AppPreferences under new misc_settings container#3543

Merged
chuckbutkus merged 6 commits into
mainfrom
refactor-misc-settings-container
Jun 9, 2026
Merged

refactor(settings): nest AppPreferences under new misc_settings container#3543
chuckbutkus merged 6 commits into
mainfrom
refactor-misc-settings-container

Conversation

@chuckbutkus

@chuckbutkus chuckbutkus commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

HUMAN:

Settings-API follow-up to #3539: that PR was merged but never released, so we're using the same schema_version=2 slot to ship a slightly different shape and to make the field fully opaque to the SDK. Please QA the settings GET/PATCH round-trip against a freshly built agent-server (no on-disk migration to test, since v2 has not shipped). Once you're happy, tick the box below.

  • A human has tested these changes.

AGENT:

Why

PR #3539 added a top-level app_preferences field to SettingsResponse / SettingsUpdateRequest (introducing schema_version=2). It is merged but has not shipped in any release, so no production user has the flat shape on disk and no production client speaks the flat wire shape.

That gives us a free window to fix two things before they lock in:

  1. Single-namespace lock-in. A flat top-level app_preferences field pins the API to one "frontend-owned" namespace. Adding any second category (e.g. UI / sidebar / view-mode state, which is the next bucket in the agent-canvas localStorage audit) would either need yet another top-level field or have to shoehorn unrelated UI state into AppPreferences. Lift it into a generic misc_settings container so future categories slot in as nested fields without churning the top-level shape.
  2. SDK awareness of frontend schema. feat(settings): add app_preferences block to persisted settings #3539 had the SDK defining AppPreferences with six typed fields (language, git_user_name, …) that are pure frontend concerns. The SDK had no reason to know those names — they're just bytes it persists on behalf of a particular client. This PR makes misc_settings a dict[str, Any] that the agent-server stores and deep-merges but never reads. The frontend owns the schema end-to-end.

It also brings the PATCH semantics in line with the other two diff fields — misc_settings_diff is deep-merged like agent_settings_diff / conversation_settings_diff instead of being a shallow overlay, so a partial update like {"misc_settings_diff": {"some_block": {"key": "value"}}} preserves the sibling keys rather than silently leaving stale state behind.

Since #3539 never shipped, this PR re-uses schema_version=2. There is no v2→v3 migration; the prior in-tree v2 shape from #3539 is treated as if it never existed.

Summary

  • SettingsResponse.misc_settings is now dict[str, Any] (was the typed MiscSettings model from the earlier round of this PR). SettingsUpdateRequest.misc_settings_diff stays dict[str, Any] | None and is deep-merged into the persisted block.
  • AppPreferences and MiscSettings classes are removed from openhands-sdk entirely — including from the public __init__.py exports. The SDK no longer knows anything about the inner shape.
  • PersistedSettings.misc_settings: dict[str, Any]. The update() method deep-merges the diff and stores the result verbatim — no model_validate, no schema, no inner clear-down. (The merged dict is now self.misc_settings itself; the secrets-defence clear() in the finally block does not apply to a non-secret opaque container.)
  • PERSISTED_SETTINGS_SCHEMA_VERSION stays at 2. A v1 file (pre-feat(settings): add app_preferences block to persisted settings #3539) still loads with misc_settings = {}; the flat v2 shape from the unreleased feat(settings): add app_preferences block to persisted settings #3539 is not supported and would error on load.
  • Tests rewritten to use neutral payloads (theme, ui, tags, count) that exercise the deep-merge / list-replacement / atomic-update properties without referencing any specific frontend schema. The "rejects invalid nested type" test from the earlier round is dropped (there's nothing to reject); replaced with test_patch_settings_misc_settings_accepts_arbitrary_payloads, which documents the opaque contract by sending a payload that would have failed the old typed validation and asserting it round-trips cleanly.

Net: +91 / -193 lines vs the previous round of this PR; zero remaining references to app_preferences or AppPreferences anywhere in openhands-sdk/, openhands-agent-server/, or tests/.

Issue Number

N/A — follow-up to #3539.

How to Test

End-to-end check against a real local agent-server:

$ uv run pytest tests/agent_server/test_settings_router.py
======================== 54 passed, 14 warnings in 10.62s ======================

$ uv run pytest tests/sdk/settings tests/sdk/utils/test_pydantic_secrets.py
======================== 87 passed, 5 warnings ===============================

$ uv run ruff check openhands-sdk/openhands/sdk/settings/ \
                    openhands-agent-server/openhands/agent_server/ \
                    tests/agent_server/test_settings_router.py
All checks passed!

$ uv run ruff format --check \
       openhands-sdk/openhands/sdk/settings/ \
       openhands-agent-server/openhands/agent_server/persistence/models.py \
       openhands-agent-server/openhands/agent_server/settings_router.py \
       tests/agent_server/test_settings_router.py
8 files already formatted

$ uv run pyright openhands-sdk/openhands/sdk/settings/ \
                 openhands-agent-server/openhands/agent_server/persistence/models.py \
                 openhands-agent-server/openhands/agent_server/settings_router.py
0 errors, 0 warnings, 0 informations

Manual round-trip:

# Fresh GET — server returns an empty dict because nothing is stored.
$ curl -s -H "X-Session-API-Key: $KEY" http://localhost:8000/api/settings | jq '.misc_settings'
{}

# PATCH with arbitrary nested shape (server treats it as opaque).
$ curl -s -X PATCH -H "X-Session-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{"misc_settings_diff": {"ui": {"sidebar": "open", "tags": ["alpha", "beta"]}, "theme": "dark"}}' \
    http://localhost:8000/api/settings | jq '.misc_settings'
{
  "ui": {"sidebar": "open", "tags": ["alpha", "beta"]},
  "theme": "dark"
}

# Partial deep-merge: updates only ui.sidebar, preserves ui.tags AND theme.
$ curl -s -X PATCH -H "X-Session-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{"misc_settings_diff": {"ui": {"sidebar": "collapsed"}}}' \
    http://localhost:8000/api/settings | jq '.misc_settings'
{
  "ui": {"sidebar": "collapsed", "tags": ["alpha", "beta"]},
  "theme": "dark"
}

# On-disk file at schema_version=2:
$ jq '{schema_version, misc_settings}' ~/.openhands/agent-server/settings.json
{
  "schema_version": 2,
  "misc_settings": {"ui": {"sidebar": "collapsed", "tags": ["alpha", "beta"]}, "theme": "dark"}
}

The matching agent-canvas update (#1191) is built against this wire shape — it owns the AppPreferences typing on the TypeScript side and posts {"misc_settings_diff": {"app_preferences": {...}}} through this exact opaque pipe. Its full test suite (3009 tests) passes, providing the cross-repo end-to-end signal.

Video/Screenshots

N/A — settings-API change, no UI surface.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Not a user-facing breaking change because the predecessor shape (#3539) never shipped.

Notes

If a developer was running off main between #3539 merging and this PR landing, their on-disk settings file may contain the flat top-level app_preferences shape at schema_version=2. After this PR lands, that file will not load — the simplest fix is to delete the file. This affects internal-only audience; no released agent-server reads or writes the flat shape.

The matching agent-canvas update is OpenHands/agent-canvas#1191.


This PR was created by an AI agent (OpenHands) on behalf of @rbren.


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:c7ff5f1-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-c7ff5f1-python \
  ghcr.io/openhands/agent-server:c7ff5f1-python

All tags pushed for this build

ghcr.io/openhands/agent-server:c7ff5f1-golang-amd64
ghcr.io/openhands/agent-server:c7ff5f1054c45ae3c687641b52369b06ff4da6da-golang-amd64
ghcr.io/openhands/agent-server:refactor-misc-settings-container-golang-amd64
ghcr.io/openhands/agent-server:c7ff5f1-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:c7ff5f1-golang-arm64
ghcr.io/openhands/agent-server:c7ff5f1054c45ae3c687641b52369b06ff4da6da-golang-arm64
ghcr.io/openhands/agent-server:refactor-misc-settings-container-golang-arm64
ghcr.io/openhands/agent-server:c7ff5f1-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:c7ff5f1-java-amd64
ghcr.io/openhands/agent-server:c7ff5f1054c45ae3c687641b52369b06ff4da6da-java-amd64
ghcr.io/openhands/agent-server:refactor-misc-settings-container-java-amd64
ghcr.io/openhands/agent-server:c7ff5f1-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:c7ff5f1-java-arm64
ghcr.io/openhands/agent-server:c7ff5f1054c45ae3c687641b52369b06ff4da6da-java-arm64
ghcr.io/openhands/agent-server:refactor-misc-settings-container-java-arm64
ghcr.io/openhands/agent-server:c7ff5f1-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:c7ff5f1-python-amd64
ghcr.io/openhands/agent-server:c7ff5f1054c45ae3c687641b52369b06ff4da6da-python-amd64
ghcr.io/openhands/agent-server:refactor-misc-settings-container-python-amd64
ghcr.io/openhands/agent-server:c7ff5f1-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:c7ff5f1-python-arm64
ghcr.io/openhands/agent-server:c7ff5f1054c45ae3c687641b52369b06ff4da6da-python-arm64
ghcr.io/openhands/agent-server:refactor-misc-settings-container-python-arm64
ghcr.io/openhands/agent-server:c7ff5f1-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:c7ff5f1-golang
ghcr.io/openhands/agent-server:c7ff5f1054c45ae3c687641b52369b06ff4da6da-golang
ghcr.io/openhands/agent-server:refactor-misc-settings-container-golang
ghcr.io/openhands/agent-server:c7ff5f1-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:c7ff5f1-java
ghcr.io/openhands/agent-server:c7ff5f1054c45ae3c687641b52369b06ff4da6da-java
ghcr.io/openhands/agent-server:refactor-misc-settings-container-java
ghcr.io/openhands/agent-server:c7ff5f1-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:c7ff5f1-python
ghcr.io/openhands/agent-server:c7ff5f1054c45ae3c687641b52369b06ff4da6da-python
ghcr.io/openhands/agent-server:refactor-misc-settings-container-python
ghcr.io/openhands/agent-server:c7ff5f1-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., c7ff5f1-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., c7ff5f1-python-amd64) are also available if needed

…iner

Follow-up to #3539. Replaces the top-level app_preferences block with a
misc_settings container that wraps it, giving the API one extension point
for frontend-owned settings the agent doesn't interpret.

API change:

  Before (v2):                       After (v3):
  ----------------------             ------------------------------
  SettingsResponse                   SettingsResponse
    .app_preferences                   .misc_settings
                                         .app_preferences

  SettingsUpdateRequest              SettingsUpdateRequest
    .app_preferences_diff              .misc_settings_diff

misc_settings_diff is deep-merged into the persisted block, consistent
with agent_settings_diff and conversation_settings_diff. Partial diffs
like {"misc_settings_diff": {"app_preferences": {"language": "fr"}}}
update only the named nested field; sibling app_preferences fields are
left alone. Lists (disabled_skills) are replaced wholesale by the
deep-merge.

Why this matters: the previous shape locked the API into a single
"frontend-owned" namespace. Adding a second category (e.g. ui_preferences
for sidebar layout / view modes) would have required either another
top-level field or shoehorning it into AppPreferences. With
misc_settings as a container, new categories drop in as additional
nested fields without churning the top-level shape.

Schema migration:

- Bumps PERSISTED_SETTINGS_SCHEMA_VERSION 2 → 3.
- from_persisted() lifts any legacy top-level app_preferences block into
  misc_settings.app_preferences on load. v1 (pre-app-preferences) and v2
  (the now-superseded flat shape from #3539) both load transparently.
- If both legacy and new shapes appear on disk (hand-edited file),
  misc_settings wins.

Removes the prior shallow-overlay semantics for app_preferences updates
in favour of consistent deep-merge across all three diff fields.

Tests:

  $ uv run pytest tests/agent_server/test_settings_router.py
  57 passed

  $ uv run pytest tests/sdk/settings
  45 passed

  $ uv run ruff check && uv run pyright openhands-sdk/openhands/sdk/settings/ \
      openhands-agent-server/openhands/agent_server/persistence/models.py    \
      openhands-agent-server/openhands/agent_server/settings_router.py
  All checks pass

Note for downstream clients (agent-canvas, etc.): the wire shape changed
in a non-backwards-compatible way. Clients reading or writing settings
need to update to misc_settings / misc_settings_diff. On-disk v2 files
are migrated automatically on first read.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Behavioral default changes detected

These public Field(default=...) changes differ from the latest released baseline, but they were already present on the base branch, so this PR was not auto-marked with the release-note-required label:

  • openhands.sdk.settings.model.OpenHandsAgentSettings.condenser: CondenserSettingsLLMSummarizingCondenserSettings

Action log

@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   settings_router.py121893%258, 260–261, 360, 362–363, 401, 406
openhands-agent-server/openhands/agent_server/persistence
   models.py2042687%297, 302, 340, 388, 423–429, 431, 433, 436, 440, 466, 483–484, 529, 533, 535, 564–567, 570
openhands-sdk/openhands/sdk/settings
   api_models.py27292%85, 87
TOTAL30153671677% 

chuckbutkus pushed a commit to OpenHands/agent-canvas that referenced this pull request Jun 6, 2026
Follow-up to the localStorage cleanup in this PR + SDK refactor in
OpenHands/software-agent-sdk#3543. The agent-server now exposes
frontend-owned settings under a generic misc_settings container instead
of a top-level app_preferences field.

Wire shape changes:

  Before:                                 After:
  GET /api/settings                       GET /api/settings
    -> { app_preferences: {...} }           -> { misc_settings: { app_preferences: {...} } }

  PATCH /api/settings                     PATCH /api/settings
    body.app_preferences_diff (shallow      body.misc_settings_diff (deep-merged,
    overlay, replaces named fields)         same semantics as agent_settings_diff)

Why the rename to misc_settings: the previous name pinned the API to a
single 'frontend-owned' namespace. Adding a future category like
ui_preferences (sidebar layout / view modes) would have required either
yet another top-level field or shoehorning unrelated UI state into
AppPreferences. With misc_settings as a container, new categories drop
in as nested fields without churning the top-level shape.

Changes:

- settings-service.api.ts
  * SettingsApiResponse.app_preferences -> .misc_settings (typed)
  * SettingsUpdateRequest.app_preferences_diff -> .misc_settings_diff
  * Add MiscSettings interface
  * transformApiResponse reads response.misc_settings?.app_preferences
  * saveSettings emits { misc_settings_diff: { app_preferences } }
  * Local 'has any diffs' check tracks misc_settings_diff
  * Doc comments updated; semantics noted as deep-merge
- legacy-app-preferences-migration.ts
  * Gate on serverResponse.misc_settings, not .app_preferences
  * pushDiff callback now wraps the diff in { app_preferences: ... }
- src/mocks/settings-handlers.ts
  * GET handler returns misc_settings.app_preferences
  * PATCH handler accepts misc_settings_diff; deep-merges nested
    app_preferences into the persisted block
  * Internal mock state stores under misc_settings to match wire shape
- __tests__/api/settings-service.test.ts
  * Four tests updated to assert the new wire shape (local PATCH body,
    GET round-trip, mixed-diff routing, legacy localStorage migration)
  * Pre-1.27 detection test now keys off missing misc_settings
- AGENTS.md
  * App-preferences note rewritten for the misc_settings container,
    explains deep-merge semantics, and documents the in-flight rename
    (flat shape introduced in #3539 never shipped to users)

Cloud path is unchanged: cloud /api/v1/settings still accepts the
fields as flat top-level keys, mirrored by saveCloudSettings.

Verification:

  $ npm run typecheck
  exit 0

  $ npm test -- __tests__/api/settings-service.test.ts \
                __tests__/api/mock-settings-handlers.test.ts
  23 tests passed

  $ npm test
  3009 passed | 12 skipped | 9 todo

  $ npm run lint
  All matched files use Prettier code style!

  $ npm run build
  built in 1.50s

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@chuckbutkus chuckbutkus requested a review from all-hands-bot June 6, 2026 03:28
@chuckbutkus chuckbutkus marked this pull request as ready for review June 6, 2026 03:28

all-hands-bot commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

🟢 Good taste — Clean, well-designed refactor.

Summary

This PR introduces a misc_settings container that wraps app_preferences, providing an extension point for future frontend-owned settings without changing the top-level API shape. Schema version bumps from 2 → 3 with automatic migration for existing persisted files.

Analysis

[CRITICAL ISSUES] — None found.

[IMPROVEMENT OPPORTUNITIES] — None. This is solid work.

[STYLE NOTES]

  • The extensive docstring in from_persisted() documenting the schema-version history is appropriate here — this is genuinely non-obvious migration logic that benefits from explanation.
  • The migration code at line 330-332 is well-guarded: misc_settings wins when both keys exist on disk.

What Works Well

  1. Migration integrity: v1 files (no app_preferences) load with empty defaults; v2 files (flat app_preferences) are automatically nested under misc_settings.app_preferences.
  2. Behavioral upgrade: app_preferences_diff was a shallow overlay; misc_settings_diff now deep-merges consistently with agent_settings_diff and conversation_settings_diff. This makes the API more predictable.
  3. Extension point: MiscSettings is a thin wrapper today but allows future categories (e.g., UI preferences) without API churn.
  4. Test coverage: Migration paths are explicitly tested for v1, v2, and the conflict case (both keys present). The deep-merge semantic for disabled_skills is also verified.

Minor Note

The PR is marked as draft — this is appropriate given the breaking API change (clients must update to use misc_settings instead of app_preferences).

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW

This is an internal schema refactor with automatic migration. Existing persisted settings are preserved. The only surface-area change is the API response shape and the diff field name, both of which clients can update at their own pace (the legacy app_preferences_diff field now correctly returns 400).

VERDICT:
Worth merging — Solid migration design, good test coverage, low risk.

KEY INSIGHT:
The schema-version migration pattern here is a good template: detect legacy shape, lift into new shape, prefer new shape when both exist, persist the migrated form.


This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

@chuckbutkus chuckbutkus requested a review from all-hands-bot June 6, 2026 03:34

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ QA Report: PASS

The settings API, SDK typed model, and legacy settings migration all behaved as described when exercised through a live agent-server and direct SDK usage.

Does this PR achieve its stated goal?

Yes. The PR set out to replace the top-level app_preferences API shape with misc_settings.app_preferences, switch writes to misc_settings_diff, deep-merge partial nested updates, loudly reject the legacy diff key, and migrate v2 persisted settings. I verified those behaviors by running the server on both main and this PR branch, making real HTTP requests to /api/settings, and importing the SDK SettingsResponse model directly.

Phase Result
Environment Setup uv run created/synced the project environment and agent-server started successfully. No tests/linters were run.
CI Status ⚠️ Observed via gh pr checks: most checks are passing, but Validate PR description and Deprecation deadlines are failing; qa-changes was in progress.
Functional Verification ✅ Live API requests and SDK calls matched the PR description, including migration and error cases.
Functional Verification

Test 1: API wire shape changed from app_preferences to misc_settings.app_preferences

Step 1 — Establish baseline on main:
Ran git checkout origin/main, started uv run agent-server --host 127.0.0.1 --port 18081, then called GET /api/settings and PATCH /api/settings with app_preferences_diff:

--- main GET shape ---
top_level_has_app_preferences= True
top_level_has_misc_settings= False
app_preferences= {'language': None, ..., 'disabled_skills': []}
--- main PATCH app_preferences_diff ---
status=200
app_preferences= {'language': 'fr', ..., 'disabled_skills': ['openhands/snake']}
--- main PATCH misc_settings_diff ---
{"detail":"At least one of agent_settings_diff, conversation_settings_diff, or app_preferences_diff must be provided"}
http_status=400

This confirms the old branch exposed and accepted the flat app_preferences shape, and did not accept misc_settings_diff.

Step 2 — Apply PR changes:
Checked out refactor-misc-settings-container at 96ba270d and restarted agent-server with an isolated OH_PERSISTENCE_DIR.

Step 3 — Re-run against the PR:
Called GET /api/settings, then PATCH /api/settings with misc_settings_diff:

--- pr GET default shape ---
top_level_has_app_preferences= False
top_level_has_misc_settings= True
misc_settings= {'app_preferences': {'language': None, ..., 'disabled_skills': []}}
--- pr PATCH misc_settings_diff initial write ---
status=200
prefs= {'language': 'fr', 'user_consents_to_analytics': True, 'enable_sound_notifications': False, 'git_user_name': 'Ada Lovelace', 'git_user_email': 'ada@example.com', 'disabled_skills': ['openhands/snake', 'openhands/python']}

This shows the PR exposes the new nested response shape and accepts the new write shape end-to-end.

Test 2: misc_settings_diff deep-merges nested fields and replaces lists wholesale

Step 1 — Baseline / prior state:
On main, there was no accepted misc_settings_diff; PATCH returned 400 as shown above. That establishes the new nested merge behavior did not exist in the old API.

Step 2 — Apply PR changes:
Used the running PR server from Test 1.

Step 3 — Exercise partial update and list replacement:
Ran partial nested PATCH requests:

--- pr PATCH misc_settings_diff partial deep merge ---
status=200
language= de
git_user_name_still= Ada Lovelace
disabled_skills_still= ['openhands/snake', 'openhands/python']
--- pr PATCH disabled_skills list replacement ---
status=200
language_still= de
disabled_skills= []
--- pr refetch persists during server session ---
language= de
git_user_email= ada@example.com
disabled_skills= []

This confirms partial diffs update only the requested nested field, preserve siblings, and replace the disabled_skills list rather than merging it.

Test 3: Legacy request key and invalid nested values fail loudly

Step 1 — Baseline / old behavior:
On main, app_preferences_diff returned 200 and updated preferences as shown in Test 1.

Step 2 — Apply PR changes:
Used the PR server at 96ba270d.

Step 3 — Re-run the legacy and invalid requests:

--- pr PATCH legacy app_preferences_diff rejected ---
{"detail":"At least one of agent_settings_diff, conversation_settings_diff, or misc_settings_diff must be provided"}
http_status=400
--- pr PATCH invalid nested type rejected ---
{"detail":"Settings validation failed"}
http_status=422

This confirms the PR does not silently accept the old client field and still validates nested app_preferences data.

Test 4: v2 persisted settings migrate to v3 response shape

Step 1 — Reproduce legacy persisted input:
Created an isolated settings.json with schema version 2 and top-level app_preferences:

{"schema_version": 2, "app_preferences": {"language": "fr", "git_user_name": "Ada Lovelace", "disabled_skills": ["openhands/snake"]}, ...}

This represents the v2 on-disk shape the PR claims to migrate.

Step 2 — Apply PR changes:
Started the PR server with OH_PERSISTENCE_DIR pointing at that file.

Step 3 — Read settings via the live API:

--- pr GET from v2 settings file ---
top_level_has_app_preferences= False
top_level_has_misc_settings= True
language= fr
git_user_name= Ada Lovelace
disabled_skills= ['openhands/snake']

I also tested a hand-edited file containing both old and new keys:

--- pr GET from v2 file containing both keys ---
top_level_has_app_preferences= False
language= fr
git_user_email= ada@example.com

This confirms legacy data is surfaced through misc_settings.app_preferences, and the new shape wins when both are present.

Test 5: SDK typed access moved to response.misc_settings.app_preferences

Step 1 — Establish typed baseline on main:
Ran a direct SDK import/call:

--- main typed SettingsResponse ---
has_app_preferences= True
has_misc_settings= False
language= fr

Step 2 — Apply PR changes:
Checked out the PR branch.

Step 3 — Re-run typed access on PR:

--- pr typed SettingsResponse ---
has_app_preferences= False
has_misc_settings= True
language= fr

This confirms typed clients now access preferences through SettingsResponse.misc_settings.app_preferences as described.

Issues Found

None from functional QA.

This review was created by an AI agent (OpenHands) on behalf of the requester.

all-hands-bot commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Taste Rating

🟢 Good taste — Clean, well-scoped refactor that adds a future-proof extension point without breaking the core functionality.


Analysis

Overall: This is a solid structural improvement. Wrapping app_preferences in a MiscSettings container before shipping stable APIs is the right call — it gives us an extension point for future categories (UI preferences, layout state) without churning the top-level wire shape. The migration logic is straightforward and the test coverage is thorough.

Data Structure: The new MiscSettings container is minimal and correct — one nested field today, easy to extend tomorrow. Good choice.

Migration Logic: The v2→v3 migration in from_persisted() handles the tiebreaker case well (misc_settings wins over legacy app_preferences). The pop() + conditional pattern is idiomatic.

Breaking Change: Properly documented as breaking. The decision to reject (400) the legacy app_preferences_diff field rather than silently accepting it is correct — it surfaces the migration requirement loudly rather than silently succeeding with stale behavior.


Testing Gaps

  • None. The test suite covers:
    • Happy path: GET/PATCH round-trip through misc_settings.app_preferences
    • Deep-merge preservation of sibling fields
    • v2 migration (flat→nested)
    • Tiebreaker when both keys are present (misc_settings wins)
    • Rejection of legacy app_preferences_diff field

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW

This is a well-tested refactor with clear migration semantics. The breaking change is intentional and properly surfaced. Agent-canvas side has been updated and its test suite passes, providing cross-repo e2e validation.


Verdict

Worth merging — The design is sound, the migration is safe, and the test coverage is comprehensive. One minor suggestion below.


Minor Suggestion

🟡 Suggestion: The _empty_app_preferences() helper in the test file (line 554) creates a fresh dict each time. Since it's used as an expected value in multiple assertions, consider whether conftest.py would be a better home for this fixture, keeping the test module focused on behavior rather than data construction. This is optional — the current approach is clear and readable.


This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ QA Report: PASS WITH ISSUES

The settings API behavior works as described end-to-end, including the new misc_settings wire shape, deep-merged partial updates, legacy diff rejection, and v2-on-disk migration; CI is not fully green.

Does this PR achieve its stated goal?

Yes. I ran the local agent-server and exercised /api/settings as an API user: the response moved from flat app_preferences to nested misc_settings.app_preferences, misc_settings_diff preserved sibling preference fields across partial PATCHes, app_preferences_diff now returns the expected 400, and a hand-written v2 settings file was loaded and rewritten as schema v3 without losing app preferences.

Phase Result
Environment Setup make build completed and installed the uv workspace dependencies.
CI Status ⚠️ gh pr checks showed 32 successful, 1 failing (Deprecation deadlines/check), 1 pending, 3 skipped.
Functional Verification ✅ Real local agent-server HTTP requests verified the changed behavior.
Functional Verification

Test 1: Wire shape and PATCH semantics

Step 1 — Establish baseline on origin/main:
Ran a local base server and queried /api/settings:

git checkout origin/main
OPENHANDS_SUPPRESS_BANNER=1 uv run python -m openhands.agent_server --host 127.0.0.1 --port 18101
curl -sS http://127.0.0.1:18101/api/settings | jq '{has_app_preferences: has("app_preferences"), has_misc_settings: has("misc_settings"), app_preferences: .app_preferences, misc_settings: .misc_settings}'

Observed:

{
  "has_app_preferences": true,
  "has_misc_settings": false,
  "app_preferences": {
    "language": null,
    "user_consents_to_analytics": null,
    "enable_sound_notifications": null,
    "git_user_name": null,
    "git_user_email": null,
    "disabled_skills": []
  },
  "misc_settings": null
}

A flat update worked on base:

{
  "status_like": "ok",
  "app_preferences": {
    "language": "fr",
    "disabled_skills": ["openhands/snake"]
  },
  "misc_settings": null
}

Sending misc_settings_diff to base returned:

HTTP/1.1 400 Bad Request
{"detail":"At least one of agent_settings_diff, conversation_settings_diff, or app_preferences_diff must be provided"}

This confirms the old user-facing API shape was flat and did not accept the new nested diff field.

Step 2 — Apply the PR changes:
Checked out refactor-misc-settings-container at 96ba270d, removed the generated settings file for a clean start, and ran a local PR server:

git checkout refactor-misc-settings-container
rm -f workspace/.openhands/settings.json
OPENHANDS_SUPPRESS_BANNER=1 uv run python -m openhands.agent_server --host 127.0.0.1 --port 18104

Step 3 — Re-run with the PR:
Fresh GET now returned the nested shape only:

{
  "has_app_preferences": false,
  "has_misc_settings": true,
  "misc_settings": {
    "app_preferences": {
      "language": null,
      "user_consents_to_analytics": null,
      "enable_sound_notifications": null,
      "git_user_name": null,
      "git_user_email": null,
      "disabled_skills": []
    }
  }
}

PATCHing misc_settings_diff with realistic user preferences worked:

{
  "language": "fr",
  "user_consents_to_analytics": null,
  "enable_sound_notifications": null,
  "git_user_name": null,
  "git_user_email": null,
  "disabled_skills": ["openhands/snake"]
}

A second partial PATCH preserved sibling fields while adding git_user_name:

{
  "language": "fr",
  "user_consents_to_analytics": null,
  "enable_sound_notifications": null,
  "git_user_name": "Ada Lovelace",
  "git_user_email": null,
  "disabled_skills": ["openhands/snake"]
}

A re-GET confirmed persistence:

{
  "language": "fr",
  "git_user_name": "Ada Lovelace",
  "disabled_skills": ["openhands/snake"]
}

List replacement also behaved as claimed:

[]

The legacy flat diff was rejected loudly:

HTTP/1.1 400 Bad Request
{"detail":"At least one of agent_settings_diff, conversation_settings_diff, or misc_settings_diff must be provided"}

This confirms the PR delivers the new wire shape and update semantics.

Test 2: v2 flat settings file migrates to v3 nested settings

Step 1 — Establish legacy on-disk input:
Wrote a v2-style settings file with top-level app_preferences:

jq '.schema_version=2 | .app_preferences={"language":"fr","git_user_name":"Ada Lovelace","disabled_skills":["openhands/snake"]} | del(.misc_settings)' workspace/.openhands/settings.json > /tmp/v2-settings.json
mv /tmp/v2-settings.json workspace/.openhands/settings.json
jq '{schema_version, has_app_preferences: has("app_preferences"), has_misc_settings: has("misc_settings"), app_preferences}' workspace/.openhands/settings.json

Observed:

{
  "schema_version": 2,
  "has_app_preferences": true,
  "has_misc_settings": false,
  "app_preferences": {
    "language": "fr",
    "git_user_name": "Ada Lovelace",
    "disabled_skills": ["openhands/snake"]
  }
}

This is the legacy flat shape the PR promises to migrate.

Step 2 — Start the PR server and read settings:

OPENHANDS_SUPPRESS_BANNER=1 uv run python -m openhands.agent_server --host 127.0.0.1 --port 18105
curl -sS http://127.0.0.1:18105/api/settings | jq '{has_app_preferences: has("app_preferences"), has_misc_settings: has("misc_settings"), prefs: .misc_settings.app_preferences}'

Observed:

{
  "has_app_preferences": false,
  "has_misc_settings": true,
  "prefs": {
    "language": "fr",
    "user_consents_to_analytics": null,
    "enable_sound_notifications": null,
    "git_user_name": "Ada Lovelace",
    "git_user_email": null,
    "disabled_skills": ["openhands/snake"]
  }
}

This confirms the server loads the legacy v2 preferences into the new nested response shape without data loss.

Step 3 — Force a write and inspect persisted v3 file:

curl -sS -X PATCH -H 'Content-Type: application/json' -d '{"misc_settings_diff":{"app_preferences":{"git_user_email":"ada@example.com"}}}' http://127.0.0.1:18105/api/settings
jq '{schema_version, has_app_preferences: has("app_preferences"), has_misc_settings: has("misc_settings"), prefs: .misc_settings.app_preferences}' workspace/.openhands/settings.json

Observed:

{
  "schema_version": 3,
  "has_app_preferences": false,
  "has_misc_settings": true,
  "prefs": {
    "language": "fr",
    "user_consents_to_analytics": null,
    "enable_sound_notifications": null,
    "git_user_name": "Ada Lovelace",
    "git_user_email": "ada@example.com",
    "disabled_skills": ["openhands/snake"]
  }
}

This confirms the v2 file is rewritten into the v3 nested persisted shape on the next update.

Issues Found

  • 🟡 CI not green: Deprecation deadlines/check is failing because openhands-tools still has a cleanup-deadline workaround at openhands-tools/openhands/tools/browser_use/logging_fix.py:16. This did not block functional verification of the settings API, but the PR is not merge-ready until CI is addressed or the check is resolved.

This review was created by an AI agent (OpenHands) on behalf of the user.

Comment thread openhands-agent-server/openhands/agent_server/persistence/models.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/persistence/models.py Outdated
Comment thread openhands-sdk/openhands/sdk/settings/__init__.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/persistence/models.py Outdated
Comment thread openhands-sdk/openhands/sdk/settings/api_models.py Outdated

@enyst enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the work on this! It seems the agent is confused now, without revert it thinks version v2 is “real”, but I think maybe best is to replace last night’s version completely

openhands-agent and others added 3 commits June 6, 2026 18:41
…es history

PR #3539 (flat top-level app_preferences at schema v2) was never released
to users, so we treat its on-disk shape as if it never existed. This PR
now stands on its own as the introduction of misc_settings at
schema_version=2 -- not a v2 -> v3 refactor.

Changes against the previous commit on this branch:

- persistence/models.py
  * PERSISTED_SETTINGS_SCHEMA_VERSION: 3 -> 2 (stays at 2).
  * Remove the v2 -> v3 migration block from from_persisted() and the
    accompanying legacy_prefs local + 'both shapes' tiebreaker. A
    file written by the unreleased #3539 shape would now fail to load
    cleanly, but no such file exists in the wild.
  * Rewrite the from_persisted() docstring to describe the simpler
    v1 (pre-misc_settings) -> v2 (current, with misc_settings) history.

- openhands-sdk/.../settings/api_models.py
  * AppPreferences and MiscSettings docstrings: 'schema v3' -> 'schema v2'.

- tests/agent_server/test_settings_router.py
  * Drop test_legacy_app_preferences_diff_field_is_rejected.
  * Drop test_persisted_settings_v2_app_preferences_migrate_to_misc_settings
    and test_persisted_settings_v2_migration_prefers_existing_misc_settings.
  * Tighten the v1-loads-with-empty-misc-settings docstring.

Co-authored-by: openhands <openhands@all-hands.dev>
The SDK had no business knowing what's nested inside misc_settings. Drop
both AppPreferences and MiscSettings entirely and store misc_settings as
a plain dict[str, Any]. The agent-server now persists and deep-merges
whatever the frontend sends, without interpreting any inner field names.

openhands-sdk/openhands/sdk/settings/api_models.py
  - Remove AppPreferences class (the 6 field declarations + docstring).
  - Remove MiscSettings class (the wrapper that exposed app_preferences).
  - SettingsResponse.misc_settings: MiscSettings -> dict[str, Any].
  - Drop unused ConfigDict import.
  - Update SettingsResponse / SettingsUpdateRequest docstrings to
    describe misc_settings as an opaque frontend-owned container.

openhands-sdk/openhands/sdk/settings/__init__.py
  - Drop AppPreferences and MiscSettings from imports and __all__.

openhands-agent-server/openhands/agent_server/persistence/models.py
  - Drop MiscSettings import.
  - PersistedSettings.misc_settings: MiscSettings -> dict[str, Any]
    with default_factory=dict.
  - update(): replace MiscSettings.model_validate(merged) with direct
    assignment of the deep-merged dict; there's nothing to validate.
  - Drop misc_merged from the finally clear-down: the merged dict is now
    assigned directly into self.misc_settings, so clearing it would wipe
    the persisted state. The clear-down was a secrets-defense mechanism
    that does not apply to a non-secret opaque container.
  - Rewrite SettingsUpdatePayload, PersistedSettings, and from_persisted
    docstrings to describe misc_settings as opaque frontend-owned data.

openhands-agent-server/openhands/agent_server/settings_router.py
  - Rewrite PATCH /api/settings docstring for misc_settings_diff to
    describe it as deep-merged into an opaque container.

tests/agent_server/test_settings_router.py
  - Rename misc_settings test block and helpers to drop app_preferences.
  - Rewrite tests with neutral payloads (theme, ui, tags, count) that
    exercise the same deep-merge / list-replacement / atomic-update
    behaviour the original tests covered, without referencing any
    specific frontend schema.
  - Drop test_patch_settings_misc_settings_rejects_invalid_type. There
    is no schema to violate now -- replaced with
    test_patch_settings_misc_settings_accepts_arbitrary_payloads, which
    documents the opaque contract by sending a payload that would have
    failed the old typed validation and asserting it round-trips.
  - Update test_persisted_settings_v1_loads_with_empty_misc_settings to
    assert misc_settings == {} instead of {'app_preferences': ...}.

Verification:

  $ uv run pytest tests/agent_server/test_settings_router.py
  54 passed in 10.62s
  $ uv run pytest tests/sdk/settings tests/sdk/utils/test_pydantic_secrets.py
  87 passed in 0.39s
  $ uv run ruff check + format     PASS
  $ uv run pyright (settings + persistence + router)   0 errors

Co-authored-by: openhands <openhands@all-hands.dev>
@chuckbutkus chuckbutkus requested a review from enyst June 8, 2026 06:23
The PR removes app_preferences/AppPreferences from the SDK and re-uses
schema_version=2 for the opaque misc_settings container. The v2 baseline
fixture still carried the old typed app_preferences shape (and an
__expected__ map referencing app_preferences.language etc.), so the
persisted-settings compatibility check failed with:

  Missing expected persisted field 'app_preferences.language'.

Replace the inner block with a neutral opaque payload (theme/ui/tags),
matching the deep-merge tests in the router. Now zero app_preferences
references remain anywhere in the SDK, agent-server, or tests.

Co-authored-by: openhands <openhands@all-hands.dev>
@chuckbutkus chuckbutkus merged commit 271a39a into main Jun 9, 2026
59 of 62 checks passed
@chuckbutkus chuckbutkus deleted the refactor-misc-settings-container branch June 9, 2026 01:21
@chuckbutkus chuckbutkus mentioned this pull request Jun 9, 2026
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants