Notify subscribers when thread read state changes (#52384)

## What changed

Add the experimental `thread/readState/changed` notification with `threadId` and the committed `readState` receipt. Send it to existing thread subscribers when terminal activity updates read state or a revert clears a removed unread position. Emit terminal read-state updates before `turn/completed` so completion remains the last notification for that result.

Expose the notification in generated JSON schemas, TypeScript types, and Python SDK models. Document experimental capability gating, subscription lifetime, notification opt-out, and refreshing receipts when concurrent responses disagree.

## Testing

Extend the read-state integration test to assert that the notification receipt matches the receipt returned by `thread/read` after terminal activity.

GitOrigin-RevId: 28935352a881b7a5e2e7f629173c3b2061bd7941
This commit is contained in:
Brent Traut
2026-10-09 02:59:21 +00:00
committed by copyberry
parent 5b3c1522f8
commit 82883da25e
22 changed files with 382 additions and 11 deletions
@@ -5602,6 +5602,43 @@
],
"type": "object"
},
"ThreadReadState": {
"description": "The first unread position in a durable local thread. A null position means read. The opaque revision guards edits against activity or marks the caller has not seen.",
"properties": {
"firstUnread": {
"anyOf": [
{
"$ref": "#/definitions/ThreadUnreadPosition"
},
{
"type": "null"
}
]
},
"revision": {
"type": "string"
}
},
"required": [
"revision"
],
"type": "object"
},
"ThreadReadStateChangedNotification": {
"properties": {
"readState": {
"$ref": "#/definitions/ThreadReadState"
},
"threadId": {
"type": "string"
}
},
"required": [
"readState",
"threadId"
],
"type": "object"
},
"ThreadRealtimeAudioChunk": {
"description": "EXPERIMENTAL - thread realtime audio chunk.",
"properties": {
@@ -6335,6 +6372,48 @@
],
"type": "object"
},
"ThreadUnreadPosition": {
"oneOf": [
{
"description": "Explicitly marked unread, including when the thread has no turns.",
"properties": {
"type": {
"enum": [
"threadStart"
],
"title": "ThreadStartThreadUnreadPositionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ThreadStartThreadUnreadPosition",
"type": "object"
},
{
"description": "The earliest completed result needing attention.",
"properties": {
"turnId": {
"type": "string"
},
"type": {
"enum": [
"turn"
],
"title": "TurnThreadUnreadPositionType",
"type": "string"
}
},
"required": [
"turnId",
"type"
],
"title": "TurnThreadUnreadPosition",
"type": "object"
}
]
},
"TokenUsageBreakdown": {
"properties": {
"cacheWriteInputTokens": {
@@ -7079,6 +7158,26 @@
"title": "Thread/status/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/readState/changed"
],
"title": "Thread/readState/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadReadStateChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/readState/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -4683,6 +4683,26 @@
"title": "Thread/status/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/readState/changed"
],
"title": "Thread/readState/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadReadStateChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/readState/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -23480,6 +23500,23 @@
],
"type": "object"
},
"ThreadReadStateChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"readState": {
"$ref": "#/definitions/v2/ThreadReadState"
},
"threadId": {
"type": "string"
}
},
"required": [
"readState",
"threadId"
],
"title": "ThreadReadStateChangedNotification",
"type": "object"
},
"ThreadRealtimeAudioChunk": {
"description": "EXPERIMENTAL - thread realtime audio chunk.",
"properties": {
@@ -16257,6 +16257,26 @@
"title": "Thread/status/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/readState/changed"
],
"title": "Thread/readState/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadReadStateChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/readState/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -21194,6 +21214,23 @@
],
"type": "object"
},
"ThreadReadStateChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"readState": {
"$ref": "#/definitions/ThreadReadState"
},
"threadId": {
"type": "string"
}
},
"required": [
"readState",
"threadId"
],
"title": "ThreadReadStateChangedNotification",
"type": "object"
},
"ThreadRealtimeAudioChunk": {
"description": "EXPERIMENTAL - thread realtime audio chunk.",
"properties": {
@@ -0,0 +1,83 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ThreadReadState": {
"description": "The first unread position in a durable local thread. A null position means read. The opaque revision guards edits against activity or marks the caller has not seen.",
"properties": {
"firstUnread": {
"anyOf": [
{
"$ref": "#/definitions/ThreadUnreadPosition"
},
{
"type": "null"
}
]
},
"revision": {
"type": "string"
}
},
"required": [
"revision"
],
"type": "object"
},
"ThreadUnreadPosition": {
"oneOf": [
{
"description": "Explicitly marked unread, including when the thread has no turns.",
"properties": {
"type": {
"enum": [
"threadStart"
],
"title": "ThreadStartThreadUnreadPositionType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ThreadStartThreadUnreadPosition",
"type": "object"
},
{
"description": "The earliest completed result needing attention.",
"properties": {
"turnId": {
"type": "string"
},
"type": {
"enum": [
"turn"
],
"title": "TurnThreadUnreadPositionType",
"type": "string"
}
},
"required": [
"turnId",
"type"
],
"title": "TurnThreadUnreadPosition",
"type": "object"
}
]
}
},
"properties": {
"readState": {
"$ref": "#/definitions/ThreadReadState"
},
"threadId": {
"type": "string"
}
},
"required": [
"readState",
"threadId"
],
"title": "ThreadReadStateChangedNotification",
"type": "object"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadReadState } from "./ThreadReadState";
export type ThreadReadStateChangedNotification = { threadId: string, readState: ThreadReadState, };
@@ -540,6 +540,7 @@ export type { ThreadQueueChangedNotification } from "./ThreadQueueChangedNotific
export type { ThreadReadParams } from "./ThreadReadParams";
export type { ThreadReadResponse } from "./ThreadReadResponse";
export type { ThreadReadState } from "./ThreadReadState";
export type { ThreadReadStateChangedNotification } from "./ThreadReadStateChangedNotification";
export type { ThreadRealtimeAudioChunk } from "./ThreadRealtimeAudioChunk";
export type { ThreadRealtimeBemItemPresentation } from "./ThreadRealtimeBemItemPresentation";
export type { ThreadRealtimeClosedNotification } from "./ThreadRealtimeClosedNotification";
@@ -1937,6 +1937,8 @@ server_notification_definitions! {
Error => "error" (v2::ErrorNotification),
ThreadStarted => "thread/started" (v2::ThreadStartedNotification),
ThreadStatusChanged => "thread/status/changed" (v2::ThreadStatusChangedNotification),
#[experimental("thread/readState/changed")]
ThreadReadStateChanged => "thread/readState/changed" (v2::ThreadReadStateChangedNotification),
ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification),
ThreadDeleted => "thread/deleted" (v2::ThreadDeletedNotification),
ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification),
@@ -2042,6 +2042,14 @@ pub enum ThreadUnreadPosition {
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadReadStateChangedNotification {
pub thread_id: String,
pub read_state: ThreadReadState,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+12 -1
View File
@@ -27,7 +27,8 @@ thread IDs on the returned page to their receipts. Null (or absent on older serv
means durable read state is unavailable; a missing map entry means that row is
ineligible. Ephemeral, not-yet-persisted, service and product-owned threads are
ineligible. Shared `Thread` objects, including search and lifecycle responses,
carry no receipts. After start, resume or fork, refresh with `thread/read`.
carry no receipts. After start, resume or fork, use `thread/read` to obtain the
current receipt.
Each local thread has one receipt, with the same storage ownership as the thread.
A read state is `{firstUnread, revision}`. `firstUnread` is null when read,
@@ -42,6 +43,16 @@ clears a removed position; explicit thread-start marks survive. Loading snapshot
never acknowledges activity; empty threads start read and forked history does
not publish inherited turns as new results.
Enabling `initialize.capabilities.experimentalApi` permits receiving read-state
notifications.
Started, resumed or forked threads send `thread/readState/changed` alongside
ordinary thread events; thread/unsubscribe or disconnect stops delivery.
Clients can filter this method with the existing notification opt-out setting.
Revisions are equality tokens, not clocks: never compare or merge them across
threads or hosts. When concurrent responses disagree, refresh with `thread/read`.
Offscreen threads converge on page refresh.
# Guardian circuit-breaker errors
Set `auto_review.circuit_break_action = "strict"` to include `TooManyDenials` in
@@ -57,6 +57,7 @@ pub(crate) fn without_notification_media(notification: ServerNotification) -> Se
ServerNotification::Error(_)
| ServerNotification::ThreadStarted(_)
| ServerNotification::ThreadStatusChanged(_)
| ServerNotification::ThreadReadStateChanged(_)
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadDeleted(_)
| ServerNotification::ThreadUnarchived(_)
@@ -364,10 +364,16 @@ pub(super) async fn ensure_listener_task_running(
conversation_id,
);
// Make reads after turn/completed reflect the committed frontier.
if let Some(summary) = terminal_summary {
super::thread_read_state::publish(
// Publish the committed receipt before turn/completed so completion
// remains the last notification for this terminal result.
if let Some(summary) = terminal_summary
&& let Some(db) = super::thread_read_state::publish(
&event, conversation_id, &conversation, &summary,
).await
{
super::thread_read_state::notify(
&db, &thread_state_manager,
&outgoing_for_task, conversation_id,
).await;
}
apply_bespoke_event_handling(
@@ -2303,12 +2303,24 @@ impl ThreadRequestProcessor {
codex_state::ReadStateOperation::Read,
)
.await
.map(|_| ()),
Ok(None) => Ok(()),
.map(|update| matches!(update, codex_state::ReadStateUpdate::Applied(_))),
Ok(None) => Ok(false),
Err(err) => Err(err),
};
if let Err(err) = retirement {
tracing::warn!("reverted history but could not update unread position: {err}")
match retirement {
Ok(true) => {
super::thread_read_state::notify(
db,
&self.thread_state_manager,
&self.outgoing,
thread_id,
)
.await;
}
Ok(false) => {}
Err(err) => {
tracing::warn!("reverted history but could not update unread position: {err}")
}
}
}
let response = self
@@ -1,7 +1,8 @@
//! Read state for durable local threads returned by the thread store.
//! Read state for durable local threads. Receipt changes use the thread's usual subscribers.
use super::*;
use codex_app_server_protocol::ThreadReadState;
use codex_app_server_protocol::ThreadReadStateChangedNotification;
use codex_app_server_protocol::ThreadUnreadPosition;
// These scopes have product notification policy the local server cannot resolve.
@@ -57,6 +58,30 @@ pub(super) async fn snapshots(
}
}
pub(super) async fn notify(
db: &StateDbHandle,
manager: &ThreadStateManager,
outgoing: &Arc<OutgoingMessageSender>,
id: ThreadId,
) {
match db.thread_read_states(&[id]).await {
Ok(mut states) => {
if let Some(state) = states.remove(&id) {
let subscribers = manager.subscribed_connection_ids(id).await;
ThreadScopedOutgoingMessageSender::new(Arc::clone(outgoing), subscribers, id)
.send_server_notification(ServerNotification::ThreadReadStateChanged(
ThreadReadStateChangedNotification {
thread_id: id.to_string(),
read_state: to_api(state),
},
))
.await;
}
}
Err(err) => tracing::warn!("could not project committed read state: {err}"),
}
}
/// Runs only on newly delivered Core events, never on history decoding or replay.
pub(super) async fn publish(
event: &codex_protocol::protocol::Event,
@@ -8,6 +8,7 @@ use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadReadStateChangedNotification;
use codex_app_server_protocol::ThreadRevertParams;
use codex_app_server_protocol::ThreadRevertResponse;
use codex_app_server_protocol::ThreadStartParams;
@@ -96,6 +97,13 @@ async fn read_state_tracks_terminal_activity_and_reverted_history() -> Result<()
assert_eq!(listed.data[0].id, thread_id);
assert_eq!(receipts, [(thread_id.clone(), first.clone())].into());
let changed: ThreadReadStateChangedNotification = timeout(
TIMEOUT,
server.read_notification("thread/readState/changed"),
)
.await??;
assert_eq!(changed.read_state, first);
let _: ThreadRevertResponse = server
.request(|request_id| ClientRequest::ThreadRevert {
request_id,
@@ -202,6 +202,7 @@ pub(super) fn server_notification_thread_target(
ServerNotification::ProjectChanged(_)
| ServerNotification::SkillsChanged(_)
| ServerNotification::AccountUpdated(_)
| ServerNotification::ThreadReadStateChanged(_)
| ServerNotification::GatewayOAuthChanged(_)
| ServerNotification::AccountRateLimitsUpdated(_)
| ServerNotification::AppListUpdated(_)
+1
View File
@@ -359,6 +359,7 @@ impl ChatWidget {
| ServerNotification::AccountRateLimitsUpdated(_)
| ServerNotification::ThreadStarted(_)
| ServerNotification::ThreadStatusChanged(_)
| ServerNotification::ThreadReadStateChanged(_)
| ServerNotification::ThreadReverted(_)
| ServerNotification::ThreadQueueChanged(_)
| ServerNotification::ThreadPredictionUpdated(_)
@@ -64,6 +64,7 @@ from .v2_all import ThreadNameUpdatedNotification
from .v2_all import ThreadPredictionUpdatedNotification
from .v2_all import ThreadProjectUpdatedNotification
from .v2_all import ThreadQueueChangedNotification
from .v2_all import ThreadReadStateChangedNotification
from .v2_all import ThreadRealtimeClosedNotification
from .v2_all import ThreadRealtimeErrorNotification
from .v2_all import ThreadRealtimeItemAddedNotification
@@ -148,6 +149,7 @@ KnownNotificationPayload: TypeAlias = (
| ThreadPredictionUpdatedNotification
| ThreadProjectUpdatedNotification
| ThreadQueueChangedNotification
| ThreadReadStateChangedNotification
| ThreadRealtimeClosedNotification
| ThreadRealtimeErrorNotification
| ThreadRealtimeItemAddedNotification
@@ -235,6 +237,7 @@ NOTIFICATION_MODELS: dict[str, type[KnownNotificationPayload]] = {
"thread/prediction/updated": ThreadPredictionUpdatedNotification,
"thread/project/updated": ThreadProjectUpdatedNotification,
"thread/queue/changed": ThreadQueueChangedNotification,
"thread/readState/changed": ThreadReadStateChangedNotification,
"thread/realtime/closed": ThreadRealtimeClosedNotification,
"thread/realtime/error": ThreadRealtimeErrorNotification,
"thread/realtime/item/completed": ThreadRealtimeItemCompletedNotification,
@@ -10249,6 +10249,14 @@ class ThreadReadState(BaseModel):
revision: str
class ThreadReadStateChangedNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
read_state: Annotated[ThreadReadState, Field(alias="readState")]
thread_id: Annotated[str, Field(alias="threadId")]
class TranscriptSegmentThreadRealtimeItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -11317,6 +11325,24 @@ class ErrorServerNotification(BaseModel):
params: ErrorNotification
class ThreadReadStateChangedServerNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
emitted_at_ms: Annotated[
int | None,
Field(
alias="emittedAtMs",
description="Unix timestamp (in milliseconds) when app-server emitted this notification.",
),
] = None
method: Annotated[
Literal["thread/readState/changed"],
Field(title="Thread/readState/changedNotificationMethod"),
]
params: ThreadReadStateChangedNotification
class ThreadGoalUpdatedServerNotification(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -13116,6 +13142,7 @@ class ServerNotification(
ErrorServerNotification
| ThreadStartedServerNotification
| ThreadStatusChangedServerNotification
| ThreadReadStateChangedServerNotification
| ThreadArchivedServerNotification
| ThreadDeletedServerNotification
| ThreadUnarchivedServerNotification
@@ -13206,6 +13233,7 @@ class ServerNotification(
ErrorServerNotification
| ThreadStartedServerNotification
| ThreadStatusChangedServerNotification
| ThreadReadStateChangedServerNotification
| ThreadArchivedServerNotification
| ThreadDeletedServerNotification
| ThreadUnarchivedServerNotification