Repository navigation
Conversation
…ons/{id}/tags/{key}
Closes OpenHands#4577.
Adds atomic single-key mutations on the conversation tag map so callers
avoid the read-modify-write race on PATCH /conversations/{id}:
- POST /api/conversations/{id}/tags/{key} – set or overwrite one tag
- DELETE /api/conversations/{id}/tags/{key} – remove one tag (404 if absent)
Neither endpoint touches updated_at so tag mutations do not perturb sort
order or localStorage-to-server migration checks.
Co-authored-by: openhands <openhands@all-hands.dev>
|
✅ Review complete. This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here. |
all-hands-bot
left a comment
There was a problem hiding this comment.
🟡 Acceptable — Clean, focused addition. The router layer is correct, validation is consistent across both handlers, the three-valued return type from the service is clearly documented, and the overall approach mirrors the existing update_conversation pattern well.
One meaningful behavioral inconsistency and a testing gap at the service layer need to be addressed before merging.
[CRITICAL ISSUES]
See inline comment on set_conversation_tag in conversation_service.py.
[TESTING GAPS]
-
No service-level tests — All new tests are router-layer tests with a mocked
ConversationService. The actual mutation logic inset_conversation_taganddelete_conversation_tag(state sync via_update_state_tags_sync, record cache invalidation,save_metacall) has no integration tests. By contrast,update_conversationhas dedicated service-level tests intest_conversation_service.py(including atest_update_conversation_tags_uses_state_lockthat verifies the state lock is held). The same coverage is needed here. -
Missing
test_delete_conversation_tag_invalid_key—test_set_conversation_tag_invalid_keyexists for POST, but there is no corresponding test for the DELETE handler's 422 path even though both handlers apply identical key validation.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
New endpoints add surface area on the conversation router. The webhook notification gap (if unintentional) could silently break external integrations that subscribe to conversation changes. The service logic itself is straightforward and follows the established pattern.
VERDICT: ❌ Needs rework — Webhook notification inconsistency must be explicitly resolved (either add the notification call, or document and test the intentional omission), and service-level tests are required.
KEY INSIGHT: The new per-key tag endpoints skip _notify_conversation_webhooks that PATCH /api/conversations/{id} fires on tag changes — this behavioral divergence is undocumented and likely unintentional.
This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
…d-key test - set_conversation_tag and delete_conversation_tag now call _notify_conversation_webhooks after mutating tags, matching the behavior of update_conversation - add test_delete_conversation_tag_invalid_key to cover 422 on the DELETE handler (mirrors the existing POST invalid-key test) Co-authored-by: openhands <openhands@all-hands.dev>
|
👋 This PR needs a couple of things fixed before OpenHands can review it:
Push an update once this is addressed and this check re-runs automatically. This is an automated check - no AI was used to generate this comment. |
|
🚦 CI is currently failing on this PR's latest commit. Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request This is an automated check - no AI was used to generate this comment. |
|
✅ Review complete. This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here. |
all-hands-bot
left a comment
There was a problem hiding this comment.
Test review with updated line numbers
all-hands-bot
left a comment
There was a problem hiding this comment.
🟡 Acceptable — feature is correct and well-structured; one real improvement opportunity and a testing gap worth addressing before the pattern solidifies.
[IMPROVEMENT OPPORTUNITIES]
[conversation_service.py, lines 1866–1879 and 1899–1912]Duplication:set_conversation_taganddelete_conversation_tagshare ~10 lines of identical boilerplate — state-sync, cache invalidation,save_meta(), webhook notification. Only the actual mutation differs. Extract a private_flush_tags_mutation(event_service, conversation_id)helper so that future changes (new cache fields, tracing, additional notification targets) only need to happen in one place.
[TESTING GAPS]
[test_conversation_tags.py]Mock-only router coverage: Every route test mocksConversationService, so they test FastAPI wiring and validation logic, but not the service methods themselves. If_update_state_tags_sync,save_meta(), or webhook notification is broken inset_conversation_tag/delete_conversation_tag, no test will catch it. A single integration-style test that calls the real service against a lightweightEventService+ temp dir would give real regression coverage here.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW — purely additive new endpoints; no changes to existing paths, data models, or persistence format.updated_atintentionally left untouched (documented in AGENTS.md).
VERDICT:
✅ Worth merging: Core logic is sound, validation and response semantics are correct, and the architectural design is clean.
KEY INSIGHT:
Both service methods are correct but copy-paste the entire persistence-and-notification sequence — that duplication is the single thing worth cleaning up before it drifts.
This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
…licate tag-write sequence set_conversation_tag and delete_conversation_tag shared identical post-mutation blocks (state sync, cache invalidation, save_meta, webhook notification). Extract into _flush_tags_mutation so future changes to the notification or caching sequence only need to happen once. Also adds test_set_delete_tag_persists_to_disk — a service-level test that injects a real EventService (without LocalConversation startup) and asserts both in-memory and on-disk tag state after each mutation. Co-authored-by: openhands <openhands@all-hands.dev>
… TAG_VALUE_MAX_LENGTH constant - Switch TAG_KEY_PATTERN.match() to .fullmatch() in conversation_router.py and types.py so trailing newlines in tag keys are rejected (re.match with $ can match "abc\n" because $ allows a newline before end-of-string) - Import and reference TAG_VALUE_MAX_LENGTH from types.py in SetTagRequest so the 256-char limit is defined in one place - Add updated_at invariant assertions to test_set_delete_tag_persists_to_disk to lock in the documented "tag mutations must not touch updated_at" contract
…lush_tags_mutation Assigns the result of run_in_executor back to `state`, matching the pattern in update_conversation and guarding against a future refactor where _update_state_tags_sync returns a new object rather than mutating in-place.
PUT /api/conversations/{id}/tags/{key} is the semantically correct HTTP
method for an idempotent upsert on a resource at a known URL. POST is
not defined as idempotent by the HTTP spec, so clients and proxies cannot
safely auto-retry on network failure. PUT carries that guarantee and
matches the intent (place this value at this path) exactly.
Nothing downstream consumes this endpoint yet, so this is a no-breakage
rename. Updates router decorator, AGENTS.md table, and all test call sites.
…tion Replaces the explicit TAG_KEY_PATTERN.fullmatch() guards in both set_conversation_tag and delete_conversation_tag with an Annotated[str, Path(pattern=...)] declaration. FastAPI enforces the constraint and returns 422 automatically, eliminating the duplicated if-block in each handler. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…updated_at omission Documents that updated_at is deliberately not bumped (per-key tag mutations must not perturb sort order or localStorage migration checks) and that the state lock is held inside run_in_executor. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…onse Matches the existing detail strings on delete_conversation_tag so clients can distinguish the failure reason consistently across both endpoints. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
… test - Correct four test docstrings that said POST instead of PUT - Add test_validate_tags_rejects_newline_in_key to pin the fullmatch fix: re.match(r'^[a-z0-9]+$', 'abc\n') matches because $ matches before a trailing newline; reverting to match would pass all tests silently Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Approved — no material findings.
This is a clean, additive change that resolves a real race: the existing full-replace PATCH /api/conversations/{id} requires a read-modify-write round trip, so two concurrent clients mutating different keys can silently drop one write. The new per-key endpoints eliminate that.
What I verified against the workspace code:
- Router (
conversation_router.py): both handlers validatekeywithTAG_KEY_PATTERNviaPath(pattern=...)and return the correct 404/422 distinctions (conversation-not-found vs. key-not-found on DELETE). Return-type handling (boolfor PUT,bool | Nonefor DELETE) is clean and unambiguous. - Service (
conversation_service.py):_flush_tags_mutationcorrectly mirrorsupdate_conversation's state-sync +save_meta()+ webhook-notification sequence, and deliberately skips theupdated_atbump (documented in AGENTS.md). The per-key mutation runs synchronously on the event loop before anyawait, so thedict(event_service.stored.tags)snapshot captures all prior mutations — no lost update across keys. fullmatchfix (types.py): a genuine hardening.re.match(r"^[a-z0-9]+$", "abc\n")previously accepted a trailing-newline key ($matches before a trailing newline);fullmatchrejects it. The new regression test pins this so a revert tomatchis caught immediately. Value length is also correctly bounded by the sharedTAG_VALUE_MAX_LENGTHconstant inSetTagRequest.- Tests: prior review feedback is fully addressed — webhook notification, DELETE invalid-key coverage, the
_flush_tags_mutationextraction, and a real persistence round-trip (test_set_delete_tag_persists_to_diskasserts both in-memorystored.tagsand on-diskmeta.json).uv run pytest tests/agent_server/test_conversation_tags.pypasses (18/18).
No blocking issues, breaking changes, or eval-risk concerns (REST surface only; no agent/tool/prompt behavior change).
[RISK ASSESSMENT]
- [Overall PR] 🟢 LOW — additive sub-resource endpoints with validation consistent with existing tag handling and adequate test coverage. The one behavioral choice worth a maintainer glance — intentionally not bumping
updated_at— is explicitly documented and covered by a test assertion.
… ConversationState ConversationState.tags (base_state.json) is the source of truth. StoredConversation.tags (meta.json) is a performance-only projection for listing/filtering. All writes must go through _flush_tags_mutation. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Scope: in the right repository. These are Agent Server REST endpoints on the canonical /api/** surface, which this repo owns, and the linked issue #4577 is open with ready-for-dev. The router/service/test structure mirrors update_conversation, and the AGENTS.md + OpenAPI expectations from the issue are addressed. No product/architecture gate needed.
One blocking finding (inline) plus one backward-compatibility finding worth a maintainer decision.
-
Concurrent per-key writes can silently drop a sibling key from
ConversationState. The PR's whole premise is that per-key updates are atomic;_flush_tags_mutationsnapshotsstored.tagsinto a plain dict and then overwritesstate.tagswholesale, so two concurrent PUTs race on which snapshot lands last in the source of truth. Reproduced with a realConversationStateand the unmodified service code: 6/60 runs leftbase_state.jsonandmeta.jsondisagreeing, and because the resume path keeps the persisted state over the passedtags, the dropped key stays dropped. Details and a suggested fix are in the inline comment. -
The
match→fullmatchchange inopenhands-sdk/openhands/sdk/conversation/types.pyis a validation tightening with no migration path. A persisted conversation whose tag key contains a trailing newline (previously accepted) can no longer be resumed — I verifiedConversationState.create(...)andStoredConversation.model_validate_jsonboth raiseValidationError. Repo policy requires a deprecation/compat path for persistence-contract changes; see the inline comment.
Verification I ran on this head (adcf6c52):
uv run pytest tests/agent_server/test_conversation_tags.py -v→ 18 passed.uv run pytest tests/agent_server/test_openapi_contract.py tests/sdk/conversation/test_tags.py→ 25 passed.- Confirmed the
Path(pattern=...)validation on both handlers rejects invalid keys and trailing-newline keys with 422 (so the route-level validation is fine; the concern in finding 2 is the model-level validator over persisted data). - Reproduced finding 1 with the real
ConversationState/ConversationServicepath, stubbing only_compose_webhook_conversation_info_sync(irrelevant to the race).
CI state for this head: every PR workflow for adcf6c52 is action_required (fork PR awaiting workflow approval), so there is no Run tests result for this head — only Validate PR description and check-pr-artifacts have completed, both successful. Local runs above are therefore the only executed evidence. The earlier all-hands-bot approvals were on 8c78369f/f320ee2a, which predate the current head.
🔄 CHANGES REQUESTED
…oints # Conflicts: # openhands-agent-server/openhands/agent_server/conversation_router.py
Concurrent PUT/DELETE /tags/{key} requests could drop a sibling key from
ConversationState: the service mutated stored.tags outside any lock, then
snapshotted it and overwrote state.tags wholesale, so an older snapshot
could land last in base_state.json.
The read-modify-write now happens on state.tags inside `with state:`, and
stored.tags is copied from the locked result. The persistence test uses a
real ConversationState, and a new test deterministically interleaves two
writes to cover the race.
Also reverts the TAG_KEY_PATTERN match -> fullmatch change: it made
previously persisted keys un-loadable with no migration, and the new
routes already reject such keys via Path(pattern=...).
Co-authored-by: openhands <openhands@all-hands.dev>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Review of 0c2e0b2c
Prior findings — verified fixed on this head
- Race /
base_state.jsonvsmeta.jsondivergence (was blocking)._mutate_state_tag_sync(conversation_service.py:380) now does the read-modify-write and thestored.tagsprojection insidewith state:, and_mutate_conversation_tagruns it viarun_in_executor. I re-ran an independent two-concurrent-PUT reproduction against this head:base_state.jsonandmeta.jsonagree in 60/60 runs (the same script diverged 6/60 onadcf6c52).test_concurrent_tag_sets_keep_every_keynow exercises a realConversationStateand deterministically interleaves two writes. fullmatchtightening breaking persisted state (was blocking).openhands-sdk/openhands/sdk/conversation/types.pyis byte-identical tomain(TAG_KEY_PATTERN.match), so previously persisted keys keep loading; the routes reject malformed keys viaPath(pattern=...)instead. Verified at the route:env→ 200,abc%0A/ABC/a-b→ 422, 257-char value → 422.- Missing webhook notification.
_mutate_conversation_tagnow calls_notify_conversation_webhooks(conversation_service.py:1899), matchingupdate_conversation.
Also verified on this head: both mutations publish a ConversationStateUpdateEvent with key tags (matching F04.patch-tags), updated_at is not bumped, and the new lock usage follows the concurrency guidance in openhands-agent-server/AGENTS.md. Focused tests pass: tests/agent_server/test_conversation_tags.py (18), test_conversation_service.py + test_openapi_contract.py (139), tests/sdk/conversation/test_tags.py + tests/cross/test_verify_agent_server_skill.py (87). CI for this head is green except the unresolved-review-threads gate, which failed at the moment the author replied and whose threads are now all resolved — a stale run, not a code defect.
Material finding
The two new routes have no owner in the repository's agent-server feature map, so the map gate documented in openhands-agent-server/AGENTS.md fails on this head. Details inline.
…oints # Conflicts: # openhands-agent-server/openhands/agent_server/conversation_router.py
Adds PUT/DELETE /api/conversations/{id}/tags/{key} to the F04 routes, with
F04.tag-key (sets/removes one key, leaves the rest and updated_at alone,
both files and the events socket) and F04.tag-key-errors (404 for an unset
key or unknown conversation, 422 for a bad key or long value, 401 without
auth). Both recipes were replayed against a live server.
Co-authored-by: openhands <openhands@all-hands.dev>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Review of e6460347
Fresh review at this head. The prior CHANGES_REQUESTED at e0db8ac5 is superseded; I re-established the current state before deciding.
Verified fixed on this head
- The two routes now have an
F04owner (F04.tag-key,F04-conversation-lifecycle.md), so the feature-map gate the previous review flagged is resolved.control-agent-server map coveragenow reports the same two pre-existing unowned routes asorigin/main(POST /api/conversations/{id}/children,POST /api/plugins/fetch) — none introduced here — andmap check --no-routespasses. openhands-sdk/.../conversation/types.pyis unchanged frommain, so previously persisted tag keys still load; malformed keys are rejected at the route viaPath(pattern=TAG_KEY_PATTERN.pattern). Verified at the route:env→ 200;ENV,a-b,env%0A→ 422.updated_atis not bumped, webhooks fire, and the mutation runs underrun_in_executorwith the state lock held, matching the async-safety guidance inopenhands-agent-server/AGENTS.md.- Tests:
tests/agent_server/test_conversation_tags.py(18) andtests/cross/test_verify_agent_server_skill.py(69) pass. Current-head check runs: 26 success, 4 skipped, 0 failing.
Material finding
A refused per-key write leaves the tag visible and later persists it. The new helper mutates in-memory state before the metadata write, which inverts the ordering update_conversation deliberately uses.
conversation_service.py:387 _mutate_state_tag_sync runs inside with state:, so state.tags = tags goes through ConversationState.__setattr__, which assigns the field and then autosaves. When the save is refused (lost lease) the write guard raises, but the in-memory field is already set; stored.tags = dict(tags) on the next line then runs only if the save succeeded, leaving state.tags and stored.tags disagreeing.
PATCH does not have this problem because it persists first: update_conversation calls await event_service.save_meta(...) before _update_state_tags_sync, so a refused write raises before any state is touched. There is an existing test asserting exactly this invariant for PATCH — test_stale_owner_refused_update_leaves_no_trace (tests/agent_server/test_conversation_service.py:512). Its docstring is "A metadata update refused for lost ownership changes nothing visible."
I reproduced this against the real lease mechanism, mirroring that test for the per-key route (expire the lease, let a second ConversationService take ownership, then set_conversation_tag on the stale owner):
set_conversation_tagraisesConversationOwnershipLostError(so the client correctly gets a 409), andmeta.json/base_state.jsonare unchanged — the guarded writes themselves behave.- But
GET /api/conversations/{id}andsearchon the stale service return{"env": "old", "team": "stale"}, andstate.tagsstill carriesteam. - The divergence is durable, not transient. After the lease is released, any later metadata-only save flushes it to disk. With a transient
meta.jsonwrite failure injected instead of a lease loss, a subsequent ordinaryPATCH(e.g. a title change) writes{"env": "old", "team": "ghost"}tometa.json— a tag the PUT returned an error for.
This matters for the downstream consumer this PR unblocks: a client that sees a failed per-key tag write and retries, or that reads tags back to decide what to send, can be misled by a tag that was never acknowledged. Suggested fix: do the metadata write before mutating state (or roll the in-memory fields back when the write raises), the same way update_conversation orders it, and add a per-key analogue of test_stale_owner_refused_update_leaves_no_trace.
Everything else in the diff looks sound. Nice work on the lock placement and the feature-map coverage.
🔄 CHANGES REQUESTED
| elif tags.pop(key, None) is None: | ||
| return False | ||
| state.tags = tags | ||
| stored.tags = dict(tags) |
There was a problem hiding this comment.
In-memory mutation is not covered by the write guard.
state.tags = tags goes through ConversationState.__setattr__, which assigns the field and then autosaves. When the save is refused (lost lease) the guard raises, but the in-memory field is already set, so state.tags and stored.tags diverge. update_conversation avoids this by calling save_meta(...) before _update_state_tags_sync, and test_stale_owner_refused_update_leaves_no_trace (tests/agent_server/test_conversation_service.py:512) pins that invariant for PATCH.
Reproduced against the real lease: with the lease expired and a second service owning the conversation, set_conversation_tag raises ConversationOwnershipLostError and disk stays correct, but GET/search on the stale service return {"env": "old", "team": "stale"} and state.tags keeps team. It is durable: a later metadata-only save flushes it — injecting a transient meta.json write failure and then issuing an ordinary title PATCH persists the never-acknowledged tag.
Suggest persisting the metadata first (or rolling the in-memory fields back on failure), as update_conversation does.
Co-authored-by: openhands <openhands@all-hands.dev>
|
📁 PR Artifacts Notice This PR contains a |
HUMAN:
My first PR in this repo. Adding new endpoints for
PUT/DELETE /api/conversations/{id}/tags/{key}. This is actually a blocker for OpenHands/OpenHands#16809. You can find more details in #4577 (comment) and in the note at the end of OpenHands/OpenHands#16809. Thank you.AGENT:
Why
The existing replaces the entire tag map, so updating a single tag requires a read-modify-write round trip. Two concurrent clients patching different keys will silently drop one write. This adds atomic per-key endpoints that eliminate the race entirely.
Summary
PUT /api/conversations/{id}/tags/{key}— set or overwrite one tag without touching othersDELETE /api/conversations/{id}/tags/{key}— remove one tag (404 if key absent)updated_at; both fire webhook notifications matchingPATCHbehaviorIssue Number
Closes #4577
How to Test
Run the tag-specific test suite:
All 16 tests should pass. The new cases are:
test_set_conversation_tag— happy pathtest_set_conversation_tag_missing_conversation— 404test_set_conversation_tag_invalid_key— 422test_delete_conversation_tag— happy pathtest_delete_conversation_tag_invalid_key— 422test_delete_conversation_tag_missing_conversation— 404 with distinct detailtest_delete_conversation_tag_missing_key— 404 with distinct detailtest_set_tag_does_not_affect_other_tags— verifies single-key callVideo/Screenshots
N/A (no UI changes)
Design Doc
N/A
Type
Notes
The full-replace
PATCH /api/conversations/{id}behavior is unchanged. Downstream work required before agent-canvas (OpenHands/OpenHands) can consume these endpoints:@openhands/typescript-client— addsetConversationTag(conversationId, key, value)anddeleteConversationTag(conversationId, key)methods toConversationClient, mirroring the new endpoints.AgentServerConversationServiceto use the new per-key methods, reinstate the localStorage migration (dropped from #16809 specifically because the old full-replace PATCH bumpsupdated_at), and simplifyupdateConversationRepository(feat: persist workspace/repo metadata as server-side conversation tags OpenHands#16666) to drop its read-modify-write pattern.Jev-Fast-Audit
⚡ Jev fast audit · estimates · 0.39s · commit adcf6c5
Strongest signal: No primary concern selected.
Evidence: No primary concern to locate.
Coverage: complete supplied coverage; 12/12 hunks, 6/6 files.
All estimates and evidence