Add experimental app-server thread read-state updates (#52395)

## Why

Clients need to mark threads read or unread without overwriting unseen results or another window's explicit mark.

## What changed

Add `thread/readState/update` for eligible durable local threads, gated by `initialize.capabilities.experimentalApi`. Requests include `threadId`, `expectedRevision`, and an operation of `{type:"read"}` or `{type:"unread"}`.

Marking read clears `firstUnread`; marking unread sets it to `threadStart`. Successful updates return the read state and notify subscribers through `thread/readState/changed`. Both operations require an exact revision match. Stale requests return `-32600` with `readStateConflict` and the current read state, requiring a fresh user action rather than a blind retry. Updates do not change thread recency.

## Testing

Add app-server coverage for experimental capability gating, explicit unread marks and notifications, conflicts for both stale operations, and marking read with a fresh revision.

GitOrigin-RevId: 775f6e9584dc91bd3c2ee9a309144b5baf0edffa
This commit is contained in:
Brent Traut
2026-10-09 03:42:19 +00:00
committed by copyberry
parent 82883da25e
commit e45069d770
15 changed files with 340 additions and 5 deletions
@@ -5761,6 +5761,29 @@ class ThreadReadParams(BaseModel):
thread_id: Annotated[str, Field(alias="threadId")]
class ReadThreadReadStateOperation(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
type: Annotated[Literal["read"], Field(title="ReadThreadReadStateOperationType")]
class UnreadThreadReadStateOperation(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
type: Annotated[Literal["unread"], Field(title="UnreadThreadReadStateOperationType")]
class ThreadReadStateOperation(
RootModel[ReadThreadReadStateOperation | UnreadThreadReadStateOperation]
):
model_config = ConfigDict(
populate_by_name=True,
)
root: ReadThreadReadStateOperation | UnreadThreadReadStateOperation
class ThreadRealtimeAudioChunk(BaseModel):
model_config = ConfigDict(
populate_by_name=True,