Skip to content

fix(acp): honor server-reported model id; keep cleared id cleared across restarts#3409

Open
simonrosenberg wants to merge 1 commit into
mainfrom
fix-acp-model-id-cold-read-meta
Open

fix(acp): honor server-reported model id; keep cleared id cleared across restarts#3409
simonrosenberg wants to merge 1 commit into
mainfrom
fix-acp-model-id-cold-read-meta

Conversation

@simonrosenberg
Copy link
Copy Markdown
Collaborator

@simonrosenberg simonrosenberg commented May 27, 2026

Summary

Follow-up to #3407. Its post-merge review (review) raised two 🟠 Important correctness holes that I verified by running locally against the merged code. Both let ConversationInfo.current_model_id (the picker/chip) advertise a model the live ACP server isn't running.

1. Cold read re-advertises a cleared override after an agent-server restart

The _initialized gate on the acp_model fallback (added in #3407) doesn't survive a restart. On a cold list read the agent is reconstructed with acp_model set and PrivateAttrs reset (_initialized=False), so for a conversation whose override init_state had deliberately cleared from acp_current_model_id (override rejected/unapplied), the fallback resurrected it.

Validated (merged code): persisted acp_session_id="prior-sess", acp_current_model_id cleared, agent acp_model="model-x", _initialized=Falsecurrent_model_id returned "model-x" ❌.

Fix: gate the fallback on no persisted acp_session_id (pre-session). Once a session exists, acp_current_model_id is authoritative (present = ran, absent = cleared), so a cleared id stays cleared across restarts. The persisted-id and live-attr fallbacks are unchanged.

2. _meta path reports the requested id, ignoring the server's reported id

For providers that select the initial model via session _meta (claude), new_session returns models.currentModelId after applying the override, so the reported id is authoritative.

Validated with the SDK-pinned claude-agent-acp@0.30.0: requesting acp_model="claude-opus-4-7" (which is even in the picker) yields server currentModelId="default", while the merged code reported "claude-opus-4-7" ❌ — a mismatch on the default config with a normal request, and both are distinct entries in available_models.

Fix: on the _meta path, prefer reported_model_id (fall back to acp_model only when the server omitted model state). The set_session_model path (codex/gemini fresh + any resume reapply) still prefers acp_model, since that call happens after the response and the reported id predates the switch.

Verification

  • Ran the real claude-agent-acp@0.30.0 and a deterministic _compose_conversation_info repro to confirm both bugs on merged main before fixing.
  • New tests: cold-read-after-restart (cleared stays cleared; applied survives), _meta-path prefers-server-id (mirrors the real claude behaviour) and omitted-models fallback. The three bug-path tests fail on the pre-fix code; two non-regression guards pass on both.
  • tests/sdk/agent/test_acp_agent.py + tests/agent_server/test_conversation_info_model.py: 259 passed. ruff/pyright clean.

🤖 Generated with Claude Code


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:37ba0be-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:37ba0be-golang-amd64
ghcr.io/openhands/agent-server:37ba0bef8b28fd089484d52e68678eea0ec15aca-golang-amd64
ghcr.io/openhands/agent-server:fix-acp-model-id-cold-read-meta-golang-amd64
ghcr.io/openhands/agent-server:37ba0be-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:37ba0be-golang-arm64
ghcr.io/openhands/agent-server:37ba0bef8b28fd089484d52e68678eea0ec15aca-golang-arm64
ghcr.io/openhands/agent-server:fix-acp-model-id-cold-read-meta-golang-arm64
ghcr.io/openhands/agent-server:37ba0be-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:37ba0be-java-amd64
ghcr.io/openhands/agent-server:37ba0bef8b28fd089484d52e68678eea0ec15aca-java-amd64
ghcr.io/openhands/agent-server:fix-acp-model-id-cold-read-meta-java-amd64
ghcr.io/openhands/agent-server:37ba0be-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:37ba0be-java-arm64
ghcr.io/openhands/agent-server:37ba0bef8b28fd089484d52e68678eea0ec15aca-java-arm64
ghcr.io/openhands/agent-server:fix-acp-model-id-cold-read-meta-java-arm64
ghcr.io/openhands/agent-server:37ba0be-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:37ba0be-python-amd64
ghcr.io/openhands/agent-server:37ba0bef8b28fd089484d52e68678eea0ec15aca-python-amd64
ghcr.io/openhands/agent-server:fix-acp-model-id-cold-read-meta-python-amd64
ghcr.io/openhands/agent-server:37ba0be-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:37ba0be-python-arm64
ghcr.io/openhands/agent-server:37ba0bef8b28fd089484d52e68678eea0ec15aca-python-arm64
ghcr.io/openhands/agent-server:fix-acp-model-id-cold-read-meta-python-arm64
ghcr.io/openhands/agent-server:37ba0be-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:37ba0be-golang
ghcr.io/openhands/agent-server:37ba0bef8b28fd089484d52e68678eea0ec15aca-golang
ghcr.io/openhands/agent-server:fix-acp-model-id-cold-read-meta-golang
ghcr.io/openhands/agent-server:37ba0be-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:37ba0be-java
ghcr.io/openhands/agent-server:37ba0bef8b28fd089484d52e68678eea0ec15aca-java
ghcr.io/openhands/agent-server:fix-acp-model-id-cold-read-meta-java
ghcr.io/openhands/agent-server:37ba0be-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:37ba0be-python
ghcr.io/openhands/agent-server:37ba0bef8b28fd089484d52e68678eea0ec15aca-python
ghcr.io/openhands/agent-server:fix-acp-model-id-cold-read-meta-python
ghcr.io/openhands/agent-server:37ba0be-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 37ba0be-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., 37ba0be-python-amd64) are also available if needed

…across restarts

Follow-up to #3407, addressing two correctness holes flagged in review after
merge. Both let ConversationInfo.current_model_id advertise a model the live
ACP server isn't actually running.

1. Cold read re-advertises a cleared override after an agent-server restart.
   The previous `_initialized` gate on the `acp_model` fallback doesn't survive
   a restart: a cold read reconstructs the agent with `acp_model` set and
   PrivateAttrs reset (`_initialized=False`), so for a conversation whose
   override `init_state` had deliberately cleared from `acp_current_model_id`,
   the fallback resurrected the model. Gate the fallback on NO persisted
   `acp_session_id` instead — once a session exists, the persisted
   `acp_current_model_id` is authoritative (present = ran, absent = cleared),
   so a cleared id stays cleared across restarts. The `acp_model` fallback now
   applies only pre-session, where it's the only hint.

2. `_meta` path reports the requested id, ignoring the server's reported id.
   For providers that select the initial model via session `_meta` (claude),
   `new_session` returns `models.currentModelId` *after* applying the override,
   so the reported id is authoritative. Real claude-agent-acp@0.30.0 reports
   `currentModelId="default"` even when a specific model (e.g.
   `claude-opus-4-7`) is requested — and both are distinct picker entries — so
   surfacing the requested id mismatched the server and the picker. Prefer
   `reported_model_id` on the `_meta` path (fall back to `acp_model` only when
   the server omitted model state). The `set_session_model` path (codex/gemini
   fresh + any resume reapply) still prefers `acp_model`, since that call
   happens after the response and the reported id predates the switch.

Tests: cold-read-after-restart (cleared stays cleared; applied survives),
`_meta`-path prefers-server-id (mirrors the real claude-acp behaviour) and
omitted-models fallback. The three bug-path tests fail on the pre-fix code.
259 passed; ruff + pyright clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   conversation_service.py60411580%144–145, 154, 180–181, 185–186, 191, 362–363, 394, 397, 404–410, 437, 443, 539, 545, 550, 556, 564–565, 574–577, 586, 598, 606, 629–630, 669, 698–702, 704–705, 708–709, 719, 731–736, 834, 841–845, 848–849, 853–857, 860–861, 865–869, 872–873, 895–896, 900–901, 903–905, 907, 910, 918–922, 925, 932–937, 939–940, 954, 964, 968, 970–971, 976–977, 983–984, 992, 1007–1008, 1038, 1053, 1081, 1375, 1378
openhands-sdk/openhands/sdk/agent
   acp_agent.py7896292%458–460, 590–591, 624, 626, 630, 634, 642, 705–706, 711, 778, 962, 965–966, 983–984, 1013, 1018, 1094, 1104, 1137–1140, 1435–1438, 1442–1444, 1447–1451, 1453, 1700, 2053–2055, 2092, 2096–2097, 2100, 2108–2110, 2112, 2114, 2118, 2121, 2130–2132, 2134, 2153, 2354–2355
TOTAL28756653377% 

Copy link
Copy Markdown
Collaborator

all-hands-bot commented May 27, 2026

Review complete.

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

Copy link
Copy Markdown
Collaborator

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

Choose a reason for hiding this comment

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

Solid follow-up fix. Both bugs are real and were correctly diagnosed.

conversation_service.py — Switching the gate from _initialized (a PrivateAttr that resets on cold restart) to has_acp_session (the persisted acp_session_id) is the right solution. Once a session exists, acp_current_model_id is authoritative by invariant: present = model the server ran, absent = override was cleared. Keying on the persisted session ID rather than the runtime flag is the only way this invariant survives agent-server restarts.

acp_agent.py — The three-way split (applied_via_meta / applied_via_call / neither) correctly maps to three semantically distinct scenarios. The core insight is captured clearly: new_session on the _meta path returns the server's post-override currentModelId in the same response (making it authoritative), whereas set_session_model fires after new_session/load_session (making reported_model_id stale and acp_model the right value to surface). The comment block explaining all three cases is clear and accurate.

Tests — Five targeted tests are added, three of which are confirmed to fail on the pre-fix code. Renaming test_cold_read_falls_back_to_acp_model_overridetest_presession_cold_read_falls_back_to_acp_model_override and updating test_live_agent_does_not_fall_back_to_unapplied_override to use acp_session_id instead of _initialized correctly tracks the semantic shift. test_cold_read_after_restart_keeps_cleared_override_cleared directly reproduces the restart-after-cleared-override scenario, and its counterpart test_cold_read_after_restart_surfaces_persisted_applied_model guards the non-regression case. The two _meta-path tests mirror the documented real-world claude-agent-acp@0.30.0 behaviour.

No blocking issues. Logic is sound and test coverage is proportionate to the risk surface.

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

Copy link
Copy Markdown
Collaborator

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

Choose a reason for hiding this comment

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

✅ QA Report: PASS

Verified the ACP model-id fixes with a real local ACP subprocess and conversation-info composition; both reported bug paths change from incorrect advertised IDs on main to correct IDs on the PR.

Does this PR achieve its stated goal?

Yes. The PR sets ConversationInfo.current_model_id to the ACP server-reported model on the _meta path (default), instead of the requested override (claude-opus-4-7), and preserves a deliberately cleared model id across a restart/cold read. I also verified side behavior remains intact: pre-session reads still fall back to acp_model, persisted applied ids still surface, and _meta sessions with no server model state still fall back to the requested id.

Phase Result
Environment Setup uv sync --dev completed earlier; verification scripts ran through uv run
CI Status ✅ PR checks observed green/skipped, with this QA check still in progress while running
Functional Verification ✅ Reproduced both bugs on origin/main; PR commit fixes both and keeps fallback behavior
Functional Verification

Test 1: _meta ACP initialization honors server-reported model id

I used a deterministic local ACP subprocess (/tmp/fake_acp_server.py) that speaks the ACP JSON-RPC methods used by ACPAgent.init_state(). It identifies as claude-agent-acp, receives the requested model via session _meta, and returns models.currentModelId = "default" with claude-opus-4-7 also listed in availableModels.

Step 1 — Reproduce on base (origin/main, fdc2bdf0):
Ran git checkout --quiet origin/main && OPENHANDS_SUPPRESS_BANNER=1 uv run python /tmp/qa_acp_model_id.py:

{
  "cold_reads": {
    "cold_read_after_restart_with_cleared_id": "model-x",
    "cold_read_after_restart_with_persisted_id": "model-x",
    "pre_session_cold_read_fallback": "model-x"
  },
  "meta_path": {
    "agent_current_model_id": "claude-opus-4-7",
    "available_model_ids": ["default", "sonnet", "haiku", "claude-opus-4-7"],
    "new_session_meta": {"claudeCode": {"options": {"model": "claude-opus-4-7"}}},
    "persisted_current_model_id": "claude-opus-4-7",
    "set_session_model_calls": []
  }
}

This confirms the bug: the server reported default, but the SDK surfaced/persisted the requested claude-opus-4-7 on the _meta path; no session/set_model call occurred after new_session, matching the claude provider path.

Step 2 — Apply the PR changes:
Checked out PR commit 37ba0bef8b28fd089484d52e68678eea0ec15aca.

Step 3 — Re-run with the fix in place:
Ran OPENHANDS_SUPPRESS_BANNER=1 uv run python /tmp/qa_acp_model_id.py:

{
  "meta_path": {
    "agent_current_model_id": "default",
    "available_model_ids": ["default", "sonnet", "haiku", "claude-opus-4-7"],
    "new_session_meta": {"claudeCode": {"options": {"model": "claude-opus-4-7"}}},
    "persisted_current_model_id": "default",
    "set_session_model_calls": []
  },
  "meta_path_omitted_models": {
    "agent_current_model_id": "claude-opus-4-7",
    "available_model_ids": [],
    "persisted_current_model_id": "claude-opus-4-7",
    "set_session_model_calls": []
  }
}

This confirms the fix: when the server reports default, the live agent and persisted state both use default; when the server omits model state, the requested id is still used as the fallback.

Test 2: Cold read after restart keeps a cleared ACP model id cleared

The script constructs conversation state the way a cold list/read would see it after restart: the serialized ACPAgent still has acp_model="model-x", but agent_state contains a prior acp_session_id and intentionally lacks acp_current_model_id because initialization cleared a rejected/unapplied override.

Step 1 — Reproduce on base (origin/main, fdc2bdf0):
Same base run as above returned:

{
  "cold_reads": {
    "cold_read_after_restart_with_cleared_id": "model-x",
    "cold_read_after_restart_with_persisted_id": "model-x",
    "pre_session_cold_read_fallback": "model-x"
  }
}

This confirms the bug: after a prior session exists and the current id was cleared, the cold read resurrects acp_model (model-x) and would advertise a model the live ACP server was not running.

Step 2 — Apply the PR changes:
Checked out PR commit 37ba0bef8b28fd089484d52e68678eea0ec15aca.

Step 3 — Re-run with the fix in place:
Same PR run returned:

{
  "cold_reads": {
    "cold_read_after_restart_with_cleared_id": null,
    "cold_read_after_restart_with_persisted_id": "model-x",
    "pre_session_cold_read_fallback": "model-x"
  }
}

This confirms the fix and guards the intended side cases: a cleared id stays cleared once a session exists, an applied persisted id still surfaces, and a pre-session cold read still falls back to the serialized acp_model.

Issues Found

None.

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

@simonrosenberg simonrosenberg requested a review from VascoSch92 May 27, 2026 18:17
@simonrosenberg simonrosenberg self-assigned this May 27, 2026
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.

3 participants