diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs
index 751701393..e8a2d26d4 100644
--- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs
+++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Actions.generated.cs
@@ -119,6 +119,8 @@ public ActionType(string value)
public static readonly ActionType ChatDraftChanged = new ActionType("chat/draftChanged");
+ public static readonly ActionType ChatIsReadChanged = new ActionType("chat/isReadChanged");
+
public static readonly ActionType ChatIsArchivedChanged = new ActionType("chat/isArchivedChanged");
public static readonly ActionType ChatInputRequested = new ActionType("chat/inputRequested");
@@ -1159,7 +1161,10 @@ public sealed record SessionChatRemovedAction
/// carried in `changes`. No-op when no entry with `chat` exists — clients
/// SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.
///
-/// Mirrors the root-channel `root/sessionSummaryChanged` notification.
+/// Mirrors the root-channel `root/sessionSummaryChanged` notification.
+/// When `changes.status` changes, the host MUST project that exact value into
+/// the matching `SessionChatSummary.status` field and publish
+/// the updated compact chat catalog through `root/sessionSummaryChanged`.
public sealed record SessionChatUpdatedAction
{
public ActionType Type { get; init; } = ActionType.SessionChatUpdated;
@@ -2015,6 +2020,23 @@ public sealed record ChatDraftChangedAction
public Message? Draft { get; init; }
}
+/// The read state of the chat changed.
+///
+/// Dispatched by a client to mark any known chat, including the owning
+/// session's default chat, as read (e.g. after viewing it) or unread. This
+/// changes only the addressed chat; it does not change the read state of its
+/// owning session or sibling chats. Use `session/isReadChanged` only to change
+/// the owning session's independent read state. After accepting this action,
+/// the host also synchronizes the addressed chat's `ChatSummary.status` and
+/// `SessionChatSummary.status` projections.
+public sealed record ChatIsReadChangedAction
+{
+ public ActionType Type { get; init; } = ActionType.ChatIsReadChanged;
+
+ /// Whether the chat has been read
+ public bool IsRead { get; init; }
+}
+
/// The archived state of the chat changed.
///
/// Dispatched by a client to archive a chat independently of its owning
@@ -2811,6 +2833,7 @@ public StateActionConverter()
["chat/pendingMessageRemoved"] = typeof(ChatPendingMessageRemovedAction),
["chat/queuedMessagesReordered"] = typeof(ChatQueuedMessagesReorderedAction),
["chat/draftChanged"] = typeof(ChatDraftChangedAction),
+ ["chat/isReadChanged"] = typeof(ChatIsReadChangedAction),
["chat/isArchivedChanged"] = typeof(ChatIsArchivedChangedAction),
["chat/inputRequested"] = typeof(ChatInputRequestedAction),
["chat/inputAnswerChanged"] = typeof(ChatInputAnswerChangedAction),
diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs
index 8e496cb27..e34aeaef9 100644
--- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs
+++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs
@@ -135,6 +135,7 @@ namespace Microsoft.AgentHostProtocol;
[JsonSerializable(typeof(ChatInputTextQuestion))]
[JsonSerializable(typeof(ChatInteractivity))]
[JsonSerializable(typeof(ChatIsArchivedChangedAction))]
+[JsonSerializable(typeof(ChatIsReadChangedAction))]
[JsonSerializable(typeof(ChatMovableChangedAction))]
[JsonSerializable(typeof(ChatMoveDestination))]
[JsonSerializable(typeof(ChatMoveDestinationKind))]
diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs
index 9ffcd684e..902b44a41 100644
--- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs
+++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs
@@ -126,7 +126,8 @@ public sealed record SessionSummaryChangedParams
/// Mutable summary fields that changed; omitted fields are unchanged.
///
/// Identity fields (`resource`, `provider`, `createdAt`) never change and
- /// MUST be omitted by senders; receivers SHOULD ignore them if present.
+ /// MUST be omitted by senders; receivers SHOULD ignore them if present.
+ /// When `chats` is present, it replaces the complete compact chat catalog.
public required PartialSessionSummary Changes { get; init; }
}
diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs
index b05b6b10e..5e116cf20 100644
--- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs
+++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs
@@ -3168,14 +3168,16 @@ public sealed record SessionChatSummary
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ChatInteractivity? Interactivity { get; init; }
- /// Whether this chat has been archived independently of its owning session
- /// (see `chat/isArchivedChanged`).
+ /// Current chat status, matching {@link ChatSummary.status}.
///
- /// Generic clients use this to group or filter archived chats in session
- /// lists without subscribing to the session channel. Absence means the
- /// chat is not archived.
+ /// Includes the activity bits and the orthogonal {@link SessionStatus.IsRead}
+ /// and {@link SessionStatus.IsArchived} flags. Generic clients use these bits
+ /// to present read, unread, or archived chats in session lists without
+ /// subscribing to the session or chat channel. Absence means the host did
+ /// not provide the status; clients MUST treat it as unknown, not as unread
+ /// or unarchived.
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public bool? Archived { get; init; }
+ public SessionStatus? Status { get; init; }
/// Aggregate summary of file changes associated with this chat.
///
diff --git a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs
index e20e88d36..890bb6aa5 100644
--- a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs
+++ b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs
@@ -112,6 +112,9 @@ public static bool TryGetActionType(object action, out ActionType actionType)
case ChatIsArchivedChangedAction value:
actionType = value.Type;
return true;
+ case ChatIsReadChangedAction value:
+ actionType = value.Type;
+ return true;
case ChatMovableChangedAction value:
actionType = value.Type;
return true;
diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs
index 9e7ce14bf..3d63486af 100644
--- a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs
+++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs
@@ -375,6 +375,8 @@ public void ApplySummaryChange(string uri, PartialSessionSummary changes)
Changes = changes.Changes ?? existing.Changes,
Annotations = existing.Annotations,
Meta = changes.Meta ?? existing.Meta,
+ Chats = changes.Chats ?? existing.Chats,
+ DefaultChat = existing.DefaultChat,
};
}
}
diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs
index af977f375..f1bef9b1e 100644
--- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs
+++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs
@@ -1130,6 +1130,9 @@ public static ReduceOutcome ApplyToChat(ChatState state, StateAction action)
case ChatDraftChangedAction a:
state.Draft = a.Draft;
return ReduceOutcome.Applied;
+ case ChatIsReadChangedAction a:
+ state.Status = WithStatusFlag(state.Status, SessionStatus.IsRead, a.IsRead);
+ return ReduceOutcome.Applied;
case ChatIsArchivedChangedAction a:
state.Status = WithStatusFlag(state.Status, SessionStatus.IsArchived, a.IsArchived);
return ReduceOutcome.Applied;
@@ -2566,6 +2569,7 @@ public static ReduceOutcome ApplyToAutomationRun(
"chat/pendingMessageRemoved",
"chat/queuedMessagesReordered",
"chat/draftChanged",
+ "chat/isReadChanged",
"chat/isArchivedChanged",
"chat/inputAnswerChanged",
"chat/inputCompleted",
diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs
index de82b2d5e..a5fbe532d 100644
--- a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs
+++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs
@@ -310,6 +310,52 @@ public void ApplySummaryChange_Meta_OverridesWhenPresent_CarriesOverWhenAbsent()
Assert.False(afterMetaPatch.Meta!["pinned"].GetBoolean());
}
+ [Fact]
+ public void ApplySummaryChange_Chats_ReplacesCompactStatusProjection()
+ {
+ var entry = new HostEntry(
+ new HostId("h"),
+ new HostConfig
+ {
+ Id = new HostId("h"),
+ TransportFactory = (_, _) => throw new InvalidOperationException(),
+ },
+ "client-1");
+ entry.PutSessionSummary(new SessionSummary
+ {
+ Resource = "ahp-session:/s1",
+ Provider = "p",
+ Title = "Session",
+ CreatedAt = "2024-01-01T00:00:00.001Z",
+ ModifiedAt = "2024-01-01T00:00:00.001Z",
+ Chats =
+ [
+ new SessionChatSummary
+ {
+ Resource = "ahp-chat:/default",
+ Title = "Default",
+ Status = SessionStatus.Idle,
+ },
+ ],
+ });
+
+ entry.ApplySummaryChange("ahp-session:/s1", new PartialSessionSummary
+ {
+ Chats =
+ [
+ new SessionChatSummary
+ {
+ Resource = "ahp-chat:/default",
+ Title = "Default",
+ Status = SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived,
+ },
+ ],
+ });
+
+ var summary = entry.Snapshot().SessionSummaries.Single(s => s.Resource == "ahp-session:/s1");
+ Assert.Equal(SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived, Assert.Single(summary.Chats!).Status);
+ }
+
// ── Upstream drift port (model config widened to JSON primitives; SessionModelInfo
// token-limit fields). ModelSelection.Config + ConfigPropertySchema.Enum carry
// arbitrary JSON primitives (not just strings), so a numeric/boolean picker value
diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs
index ad32be82f..28f4aa0d5 100644
--- a/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs
+++ b/clients/dotnet/tests/AgentHostProtocol.Tests/NativeReducerTests.cs
@@ -188,6 +188,17 @@ public void ClientDispatchable_TrueForChatIsArchivedChanged()
Assert.True(Reducers.IsClientDispatchable(action));
}
+ [Fact]
+ public void ClientDispatchable_TrueForChatIsReadChanged()
+ {
+ var action = new StateAction(new ChatIsReadChangedAction
+ {
+ Type = ActionType.ChatIsReadChanged,
+ IsRead = true,
+ });
+ Assert.True(Reducers.IsClientDispatchable(action));
+ }
+
// AHP 0.6.0 (#328): changeset/filesReviewChanged is the first client-dispatchable
// changeset action — a reviewer toggles per-file review state directly through the
// write-ahead reducer. Every other changeset/* action remains server-only.
diff --git a/clients/go/ahp/reducers.go b/clients/go/ahp/reducers.go
index ebef72956..198e5ce03 100644
--- a/clients/go/ahp/reducers.go
+++ b/clients/go/ahp/reducers.go
@@ -850,6 +850,9 @@ func ApplyActionToChat(state *ahptypes.ChatState, action ahptypes.StateAction) R
case *ahptypes.ChatDraftChangedAction:
state.Draft = a.Draft
return ReduceOutcomeApplied
+ case *ahptypes.ChatIsReadChangedAction:
+ state.Status = withStatusFlag(state.Status, ahptypes.SessionStatusIsRead, a.IsRead)
+ return ReduceOutcomeApplied
case *ahptypes.ChatIsArchivedChangedAction:
state.Status = withStatusFlag(state.Status, ahptypes.SessionStatusIsArchived, a.IsArchived)
return ReduceOutcomeApplied
diff --git a/clients/go/ahptypes/actions.generated.go b/clients/go/ahptypes/actions.generated.go
index dc5e6f386..986ba6ddf 100644
--- a/clients/go/ahptypes/actions.generated.go
+++ b/clients/go/ahptypes/actions.generated.go
@@ -66,6 +66,7 @@ const (
ActionTypeChatPendingMessageRemoved ActionType = "chat/pendingMessageRemoved"
ActionTypeChatQueuedMessagesReordered ActionType = "chat/queuedMessagesReordered"
ActionTypeChatDraftChanged ActionType = "chat/draftChanged"
+ ActionTypeChatIsReadChanged ActionType = "chat/isReadChanged"
ActionTypeChatIsArchivedChanged ActionType = "chat/isArchivedChanged"
ActionTypeChatInputRequested ActionType = "chat/inputRequested"
ActionTypeChatInputAnswerChanged ActionType = "chat/inputAnswerChanged"
@@ -211,6 +212,9 @@ type SessionChatRemovedAction struct {
// SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.
//
// Mirrors the root-channel `root/sessionSummaryChanged` notification.
+// When `changes.status` changes, the host MUST project that exact value into
+// the matching `SessionChatSummary.status` field and publish
+// the updated compact chat catalog through `root/sessionSummaryChanged`.
type SessionChatUpdatedAction struct {
Type ActionType `json:"type"`
// The URI of the chat whose summary changed.
@@ -813,6 +817,21 @@ type ChatDraftChangedAction struct {
Draft *Message `json:"draft,omitempty"`
}
+// The read state of the chat changed.
+//
+// Dispatched by a client to mark any known chat, including the owning
+// session's default chat, as read (e.g. after viewing it) or unread. This
+// changes only the addressed chat; it does not change the read state of its
+// owning session or sibling chats. Use `session/isReadChanged` only to change
+// the owning session's independent read state. After accepting this action,
+// the host also synchronizes the addressed chat's `ChatSummary.status` and
+// `SessionChatSummary.status` projections.
+type ChatIsReadChangedAction struct {
+ Type ActionType `json:"type"`
+ // Whether the chat has been read
+ IsRead bool `json:"isRead"`
+}
+
// The archived state of the chat changed.
//
// Dispatched by a client to archive a chat independently of its owning
@@ -1801,6 +1820,7 @@ func (*ChatPendingMessageSetAction) isStateAction() {}
func (*ChatPendingMessageRemovedAction) isStateAction() {}
func (*ChatQueuedMessagesReorderedAction) isStateAction() {}
func (*ChatDraftChangedAction) isStateAction() {}
+func (*ChatIsReadChangedAction) isStateAction() {}
func (*ChatIsArchivedChangedAction) isStateAction() {}
func (*ChatInputRequestedAction) isStateAction() {}
func (*ChatInputAnswerChangedAction) isStateAction() {}
@@ -2109,6 +2129,12 @@ func (u *StateAction) UnmarshalJSON(data []byte) error {
return err
}
u.Value = &value
+ case "chat/isReadChanged":
+ var value ChatIsReadChangedAction
+ if err := json.Unmarshal(data, &value); err != nil {
+ return err
+ }
+ u.Value = &value
case "chat/isArchivedChanged":
var value ChatIsArchivedChangedAction
if err := json.Unmarshal(data, &value); err != nil {
diff --git a/clients/go/ahptypes/notifications.generated.go b/clients/go/ahptypes/notifications.generated.go
index a62d924ae..e19b34487 100644
--- a/clients/go/ahptypes/notifications.generated.go
+++ b/clients/go/ahptypes/notifications.generated.go
@@ -83,6 +83,7 @@ type SessionSummaryChangedParams struct {
//
// Identity fields (`resource`, `provider`, `createdAt`) never change and
// MUST be omitted by senders; receivers SHOULD ignore them if present.
+ // When `chats` is present, it replaces the complete compact chat catalog.
Changes PartialSessionSummary `json:"changes"`
}
diff --git a/clients/go/ahptypes/state.generated.go b/clients/go/ahptypes/state.generated.go
index 4cc70c783..92da47d03 100644
--- a/clients/go/ahptypes/state.generated.go
+++ b/clients/go/ahptypes/state.generated.go
@@ -1229,13 +1229,15 @@ type SessionChatSummary struct {
// read-only chats. Absence defaults to {@link ChatInteractivity.Full} for
// backward compatibility.
Interactivity *ChatInteractivity `json:"interactivity,omitempty"`
- // Whether this chat has been archived independently of its owning session
- // (see `chat/isArchivedChanged`).
- //
- // Generic clients use this to group or filter archived chats in session
- // lists without subscribing to the session channel. Absence means the
- // chat is not archived.
- Archived *bool `json:"archived,omitempty"`
+ // Current chat status, matching {@link ChatSummary.status}.
+ //
+ // Includes the activity bits and the orthogonal {@link SessionStatus.IsRead}
+ // and {@link SessionStatus.IsArchived} flags. Generic clients use these bits
+ // to present read, unread, or archived chats in session lists without
+ // subscribing to the session or chat channel. Absence means the host did
+ // not provide the status; clients MUST treat it as unknown, not as unread
+ // or unarchived.
+ Status *SessionStatus `json:"status,omitempty"`
// Aggregate summary of file changes associated with this chat.
//
// Servers may populate this so session lists can show per-chat change
diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt
index 8fd22a745..3b2a4d0c2 100644
--- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt
+++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/Reducers.kt
@@ -1614,6 +1614,10 @@ public fun chatReducer(state: ChatState, action: StateAction): ChatState = when
is StateActionChatDraftChanged -> state.copy(draft = action.value.draft)
+ is StateActionChatIsReadChanged -> state.copy(
+ status = withStatusFlag(state.status, SessionStatus.IS_READ, action.value.isRead),
+ )
+
is StateActionChatIsArchivedChanged -> state.copy(
status = withStatusFlag(state.status, SessionStatus.IS_ARCHIVED, action.value.isArchived),
)
diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt
index 3607b366b..54cd74292 100644
--- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt
+++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Actions.generated.kt
@@ -76,6 +76,7 @@ value class ActionType(val rawValue: String) {
val CHAT_PENDING_MESSAGE_REMOVED: ActionType = ActionType("chat/pendingMessageRemoved")
val CHAT_QUEUED_MESSAGES_REORDERED: ActionType = ActionType("chat/queuedMessagesReordered")
val CHAT_DRAFT_CHANGED: ActionType = ActionType("chat/draftChanged")
+ val CHAT_IS_READ_CHANGED: ActionType = ActionType("chat/isReadChanged")
val CHAT_IS_ARCHIVED_CHANGED: ActionType = ActionType("chat/isArchivedChanged")
val CHAT_INPUT_REQUESTED: ActionType = ActionType("chat/inputRequested")
val CHAT_INPUT_ANSWER_CHANGED: ActionType = ActionType("chat/inputAnswerChanged")
@@ -1017,6 +1018,15 @@ data class ChatDraftChangedAction(
val draft: Message? = null
)
+@Serializable
+data class ChatIsReadChangedAction(
+ val type: ActionType,
+ /**
+ * Whether the chat has been read
+ */
+ val isRead: Boolean
+)
+
@Serializable
data class ChatIsArchivedChangedAction(
val type: ActionType,
@@ -1713,6 +1723,7 @@ sealed interface StateAction
@JvmInline value class StateActionChatPendingMessageRemoved(val value: ChatPendingMessageRemovedAction) : StateAction
@JvmInline value class StateActionChatQueuedMessagesReordered(val value: ChatQueuedMessagesReorderedAction) : StateAction
@JvmInline value class StateActionChatDraftChanged(val value: ChatDraftChangedAction) : StateAction
+@JvmInline value class StateActionChatIsReadChanged(val value: ChatIsReadChangedAction) : StateAction
@JvmInline value class StateActionChatIsArchivedChanged(val value: ChatIsArchivedChangedAction) : StateAction
@JvmInline value class StateActionChatInputRequested(val value: ChatInputRequestedAction) : StateAction
@JvmInline value class StateActionChatInputAnswerChanged(val value: ChatInputAnswerChangedAction) : StateAction
@@ -1831,6 +1842,7 @@ internal object StateActionSerializer : KSerializer {
"chat/pendingMessageRemoved" -> StateActionChatPendingMessageRemoved(input.json.decodeFromJsonElement(ChatPendingMessageRemovedAction.serializer(), element))
"chat/queuedMessagesReordered" -> StateActionChatQueuedMessagesReordered(input.json.decodeFromJsonElement(ChatQueuedMessagesReorderedAction.serializer(), element))
"chat/draftChanged" -> StateActionChatDraftChanged(input.json.decodeFromJsonElement(ChatDraftChangedAction.serializer(), element))
+ "chat/isReadChanged" -> StateActionChatIsReadChanged(input.json.decodeFromJsonElement(ChatIsReadChangedAction.serializer(), element))
"chat/isArchivedChanged" -> StateActionChatIsArchivedChanged(input.json.decodeFromJsonElement(ChatIsArchivedChangedAction.serializer(), element))
"chat/inputRequested" -> StateActionChatInputRequested(input.json.decodeFromJsonElement(ChatInputRequestedAction.serializer(), element))
"chat/inputAnswerChanged" -> StateActionChatInputAnswerChanged(input.json.decodeFromJsonElement(ChatInputAnswerChangedAction.serializer(), element))
@@ -1942,6 +1954,7 @@ internal object StateActionSerializer : KSerializer {
is StateActionChatPendingMessageRemoved -> output.json.encodeToJsonElement(ChatPendingMessageRemovedAction.serializer(), value.value)
is StateActionChatQueuedMessagesReordered -> output.json.encodeToJsonElement(ChatQueuedMessagesReorderedAction.serializer(), value.value)
is StateActionChatDraftChanged -> output.json.encodeToJsonElement(ChatDraftChangedAction.serializer(), value.value)
+ is StateActionChatIsReadChanged -> output.json.encodeToJsonElement(ChatIsReadChangedAction.serializer(), value.value)
is StateActionChatIsArchivedChanged -> output.json.encodeToJsonElement(ChatIsArchivedChangedAction.serializer(), value.value)
is StateActionChatInputRequested -> output.json.encodeToJsonElement(ChatInputRequestedAction.serializer(), value.value)
is StateActionChatInputAnswerChanged -> output.json.encodeToJsonElement(ChatInputAnswerChangedAction.serializer(), value.value)
diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt
index 33698e2b0..2016b2d38 100644
--- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt
+++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt
@@ -92,6 +92,7 @@ data class SessionSummaryChangedParams(
*
* Identity fields (`resource`, `provider`, `createdAt`) never change and
* MUST be omitted by senders; receivers SHOULD ignore them if present.
+ * When `chats` is present, it replaces the complete compact chat catalog.
*/
val changes: PartialSessionSummary
)
diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt
index 87ba426a8..770f9e469 100644
--- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt
+++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/State.generated.kt
@@ -2290,14 +2290,16 @@ data class SessionChatSummary(
*/
val interactivity: ChatInteractivity? = null,
/**
- * Whether this chat has been archived independently of its owning session
- * (see `chat/isArchivedChanged`).
+ * Current chat status, matching {@link ChatSummary.status}.
*
- * Generic clients use this to group or filter archived chats in session
- * lists without subscribing to the session channel. Absence means the
- * chat is not archived.
+ * Includes the activity bits and the orthogonal {@link SessionStatus.IsRead}
+ * and {@link SessionStatus.IsArchived} flags. Generic clients use these bits
+ * to present read, unread, or archived chats in session lists without
+ * subscribing to the session or chat channel. Absence means the host did
+ * not provide the status; clients MUST treat it as unknown, not as unread
+ * or unarchived.
*/
- val archived: Boolean? = null,
+ val status: SessionStatus? = null,
/**
* Aggregate summary of file changes associated with this chat.
*
diff --git a/clients/rust/crates/ahp-types/src/actions.rs b/clients/rust/crates/ahp-types/src/actions.rs
index 776a1ed7e..7a28a266f 100644
--- a/clients/rust/crates/ahp-types/src/actions.rs
+++ b/clients/rust/crates/ahp-types/src/actions.rs
@@ -77,6 +77,7 @@ pub enum ActionType {
ChatPendingMessageRemoved,
ChatQueuedMessagesReordered,
ChatDraftChanged,
+ ChatIsReadChanged,
ChatIsArchivedChanged,
ChatInputRequested,
ChatInputAnswerChanged,
@@ -218,6 +219,7 @@ impl serde::Serialize for ActionType {
serializer.serialize_str("chat/queuedMessagesReordered")
}
Self::ChatDraftChanged => serializer.serialize_str("chat/draftChanged"),
+ Self::ChatIsReadChanged => serializer.serialize_str("chat/isReadChanged"),
Self::ChatIsArchivedChanged => serializer.serialize_str("chat/isArchivedChanged"),
Self::ChatInputRequested => serializer.serialize_str("chat/inputRequested"),
Self::ChatInputAnswerChanged => serializer.serialize_str("chat/inputAnswerChanged"),
@@ -369,6 +371,7 @@ impl<'de> serde::Deserialize<'de> for ActionType {
"chat/pendingMessageRemoved" => Self::ChatPendingMessageRemoved,
"chat/queuedMessagesReordered" => Self::ChatQueuedMessagesReordered,
"chat/draftChanged" => Self::ChatDraftChanged,
+ "chat/isReadChanged" => Self::ChatIsReadChanged,
"chat/isArchivedChanged" => Self::ChatIsArchivedChanged,
"chat/inputRequested" => Self::ChatInputRequested,
"chat/inputAnswerChanged" => Self::ChatInputAnswerChanged,
@@ -535,6 +538,9 @@ pub struct SessionChatRemovedAction {
/// SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.
///
/// Mirrors the root-channel `root/sessionSummaryChanged` notification.
+/// When `changes.status` changes, the host MUST project that exact value into
+/// the matching `SessionChatSummary.status` field and publish
+/// the updated compact chat catalog through `root/sessionSummaryChanged`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionChatUpdatedAction {
@@ -1476,6 +1482,22 @@ pub struct ChatDraftChangedAction {
pub draft: Option,
}
+/// The read state of the chat changed.
+///
+/// Dispatched by a client to mark any known chat, including the owning
+/// session's default chat, as read (e.g. after viewing it) or unread. This
+/// changes only the addressed chat; it does not change the read state of its
+/// owning session or sibling chats. Use `session/isReadChanged` only to change
+/// the owning session's independent read state. After accepting this action,
+/// the host also synchronizes the addressed chat's `ChatSummary.status` and
+/// `SessionChatSummary.status` projections.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ChatIsReadChangedAction {
+ /// Whether the chat has been read
+ pub is_read: bool,
+}
+
/// The archived state of the chat changed.
///
/// Dispatched by a client to archive a chat independently of its owning
@@ -2431,6 +2453,8 @@ pub enum StateAction {
ChatQueuedMessagesReordered(ChatQueuedMessagesReorderedAction),
#[serde(rename = "chat/draftChanged")]
ChatDraftChanged(ChatDraftChangedAction),
+ #[serde(rename = "chat/isReadChanged")]
+ ChatIsReadChanged(ChatIsReadChangedAction),
#[serde(rename = "chat/isArchivedChanged")]
ChatIsArchivedChanged(ChatIsArchivedChangedAction),
#[serde(rename = "chat/inputRequested")]
diff --git a/clients/rust/crates/ahp-types/src/notifications.rs b/clients/rust/crates/ahp-types/src/notifications.rs
index b6ff1c587..0e37759fe 100644
--- a/clients/rust/crates/ahp-types/src/notifications.rs
+++ b/clients/rust/crates/ahp-types/src/notifications.rs
@@ -122,6 +122,7 @@ pub struct SessionSummaryChangedParams {
///
/// Identity fields (`resource`, `provider`, `createdAt`) never change and
/// MUST be omitted by senders; receivers SHOULD ignore them if present.
+ /// When `chats` is present, it replaces the complete compact chat catalog.
pub changes: PartialSessionSummary,
}
diff --git a/clients/rust/crates/ahp-types/src/state.rs b/clients/rust/crates/ahp-types/src/state.rs
index 06fc17955..05ce9e49f 100644
--- a/clients/rust/crates/ahp-types/src/state.rs
+++ b/clients/rust/crates/ahp-types/src/state.rs
@@ -2571,14 +2571,16 @@ pub struct SessionChatSummary {
/// backward compatibility.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interactivity: Option,
- /// Whether this chat has been archived independently of its owning session
- /// (see `chat/isArchivedChanged`).
+ /// Current chat status, matching {@link ChatSummary.status}.
///
- /// Generic clients use this to group or filter archived chats in session
- /// lists without subscribing to the session channel. Absence means the
- /// chat is not archived.
+ /// Includes the activity bits and the orthogonal {@link SessionStatus.IsRead}
+ /// and {@link SessionStatus.IsArchived} flags. Generic clients use these bits
+ /// to present read, unread, or archived chats in session lists without
+ /// subscribing to the session or chat channel. Absence means the host did
+ /// not provide the status; clients MUST treat it as unknown, not as unread
+ /// or unarchived.
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub archived: Option,
+ pub status: Option,
/// Aggregate summary of file changes associated with this chat.
///
/// Servers may populate this so session lists can show per-chat change
diff --git a/clients/rust/crates/ahp/src/hosts/runtime.rs b/clients/rust/crates/ahp/src/hosts/runtime.rs
index 6bfa33d7d..976d0399a 100644
--- a/clients/rust/crates/ahp/src/hosts/runtime.rs
+++ b/clients/rust/crates/ahp/src/hosts/runtime.rs
@@ -740,6 +740,9 @@ fn apply_summary_changes(
if let Some(v) = &changes.changes {
existing.changes = Some(v.clone());
}
+ if let Some(v) = &changes.chats {
+ existing.chats = Some(v.clone());
+ }
}
// ─── Random helpers (no external dep on `rand`) ─────────────────────────────
@@ -764,3 +767,53 @@ fn jitter_sample() -> f64 {
// Map 64 random bits into [0.0, 1.0).
(raw as f64) / (u64::MAX as f64)
}
+
+#[cfg(test)]
+mod tests {
+ use super::apply_summary_changes;
+ use ahp_types::notifications::PartialSessionSummary;
+ use ahp_types::state::SessionSummary;
+ use serde_json::json;
+
+ #[test]
+ fn summary_changes_replace_compact_chat_status_projection() {
+ let mut summary: SessionSummary = serde_json::from_value(json!({
+ "resource": "ahp-session:/s1",
+ "provider": "copilot",
+ "title": "Session",
+ "status": 1,
+ "createdAt": "2026-10-02T00:00:00.000Z",
+ "modifiedAt": "2026-10-02T00:00:00.000Z",
+ "chats": [{
+ "resource": "ahp-chat:/default",
+ "title": "Default",
+ "status": 1
+ }]
+ }))
+ .expect("valid session summary");
+ let changes: PartialSessionSummary = serde_json::from_value(json!({
+ "chats": [{
+ "resource": "ahp-chat:/default",
+ "title": "Default",
+ "status": 97
+ }]
+ }))
+ .expect("valid session summary changes");
+
+ apply_summary_changes(&mut summary, &changes);
+
+ assert_eq!(
+ summary
+ .chats
+ .as_ref()
+ .and_then(|chats| chats.first())
+ .and_then(|chat| chat.status),
+ Some(
+ (ahp_types::state::SessionStatus::Idle
+ | ahp_types::state::SessionStatus::IsRead
+ | ahp_types::state::SessionStatus::IsArchived)
+ .bits()
+ )
+ );
+ }
+}
diff --git a/clients/rust/crates/ahp/src/reducers.rs b/clients/rust/crates/ahp/src/reducers.rs
index 76602c966..a5c2278e2 100644
--- a/clients/rust/crates/ahp/src/reducers.rs
+++ b/clients/rust/crates/ahp/src/reducers.rs
@@ -1409,6 +1409,10 @@ pub fn apply_action_to_chat(state: &mut ChatState, action: &StateAction) -> Redu
state.draft = a.draft.clone();
ReduceOutcome::Applied
}
+ StateAction::ChatIsReadChanged(a) => {
+ state.status = with_status_flag(state.status, SessionStatus::IsRead, a.is_read);
+ ReduceOutcome::Applied
+ }
StateAction::ChatIsArchivedChanged(a) => {
state.status = with_status_flag(state.status, SessionStatus::IsArchived, a.is_archived);
ReduceOutcome::Applied
diff --git a/clients/swift/AHPApp/AHPApp/Store/AppStore.swift b/clients/swift/AHPApp/AHPApp/Store/AppStore.swift
index d9ad94e54..2911ee0e2 100644
--- a/clients/swift/AHPApp/AHPApp/Store/AppStore.swift
+++ b/clients/swift/AHPApp/AHPApp/Store/AppStore.swift
@@ -1305,6 +1305,7 @@ final class AppStore {
if let v = changes.project { summary.project = v }
if let v = changes.annotations { summary.annotations = v }
if let v = changes.workingDirectory { summary.workingDirectory = v }
+ if let v = changes.chats { summary.chats = v }
sessionSummariesCache[uri] = summary
}
diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift
index 991813b78..ebc22c3b2 100644
--- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift
+++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Actions.generated.swift
@@ -53,6 +53,7 @@ public enum ActionType: Codable, Sendable, Equatable {
case chatPendingMessageRemoved
case chatQueuedMessagesReordered
case chatDraftChanged
+ case chatIsReadChanged
case chatIsArchivedChanged
case chatInputRequested
case chatInputAnswerChanged
@@ -163,6 +164,7 @@ public enum ActionType: Codable, Sendable, Equatable {
case "chat/pendingMessageRemoved": self = .chatPendingMessageRemoved
case "chat/queuedMessagesReordered": self = .chatQueuedMessagesReordered
case "chat/draftChanged": self = .chatDraftChanged
+ case "chat/isReadChanged": self = .chatIsReadChanged
case "chat/isArchivedChanged": self = .chatIsArchivedChanged
case "chat/inputRequested": self = .chatInputRequested
case "chat/inputAnswerChanged": self = .chatInputAnswerChanged
@@ -273,6 +275,7 @@ public enum ActionType: Codable, Sendable, Equatable {
case .chatPendingMessageRemoved: try container.encode("chat/pendingMessageRemoved")
case .chatQueuedMessagesReordered: try container.encode("chat/queuedMessagesReordered")
case .chatDraftChanged: try container.encode("chat/draftChanged")
+ case .chatIsReadChanged: try container.encode("chat/isReadChanged")
case .chatIsArchivedChanged: try container.encode("chat/isArchivedChanged")
case .chatInputRequested: try container.encode("chat/inputRequested")
case .chatInputAnswerChanged: try container.encode("chat/inputAnswerChanged")
@@ -1624,6 +1627,20 @@ public struct ChatDraftChangedAction: Codable, Sendable {
}
}
+public struct ChatIsReadChangedAction: Codable, Sendable {
+ public var type: ActionType
+ /// Whether the chat has been read
+ public var isRead: Bool
+
+ public init(
+ type: ActionType,
+ isRead: Bool
+ ) {
+ self.type = type
+ self.isRead = isRead
+ }
+}
+
public struct ChatIsArchivedChangedAction: Codable, Sendable {
public var type: ActionType
/// Whether the chat is archived
@@ -2577,6 +2594,7 @@ public enum StateAction: Codable, Sendable {
case chatPendingMessageRemoved(ChatPendingMessageRemovedAction)
case chatQueuedMessagesReordered(ChatQueuedMessagesReorderedAction)
case chatDraftChanged(ChatDraftChangedAction)
+ case chatIsReadChanged(ChatIsReadChangedAction)
case chatIsArchivedChanged(ChatIsArchivedChangedAction)
case chatInputRequested(ChatInputRequestedAction)
case chatInputAnswerChanged(ChatInputAnswerChangedAction)
@@ -2743,6 +2761,8 @@ public enum StateAction: Codable, Sendable {
self = .chatQueuedMessagesReordered(try ChatQueuedMessagesReorderedAction(from: decoder))
case "chat/draftChanged":
self = .chatDraftChanged(try ChatDraftChangedAction(from: decoder))
+ case "chat/isReadChanged":
+ self = .chatIsReadChanged(try ChatIsReadChangedAction(from: decoder))
case "chat/isArchivedChanged":
self = .chatIsArchivedChanged(try ChatIsArchivedChangedAction(from: decoder))
case "chat/inputRequested":
@@ -2905,6 +2925,7 @@ public enum StateAction: Codable, Sendable {
case .chatPendingMessageRemoved(let v): try v.encode(to: encoder)
case .chatQueuedMessagesReordered(let v): try v.encode(to: encoder)
case .chatDraftChanged(let v): try v.encode(to: encoder)
+ case .chatIsReadChanged(let v): try v.encode(to: encoder)
case .chatIsArchivedChanged(let v): try v.encode(to: encoder)
case .chatInputRequested(let v): try v.encode(to: encoder)
case .chatInputAnswerChanged(let v): try v.encode(to: encoder)
diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift
index a2080ebf1..5e105384c 100644
--- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift
+++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift
@@ -75,6 +75,7 @@ public struct SessionSummaryChangedParams: Codable, Sendable {
///
/// Identity fields (`resource`, `provider`, `createdAt`) never change and
/// MUST be omitted by senders; receivers SHOULD ignore them if present.
+ /// When `chats` is present, it replaces the complete compact chat catalog.
public var changes: PartialSessionSummary
public init(
diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift
index fd43ba1af..9b4c8d364 100644
--- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift
+++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/State.generated.swift
@@ -2439,13 +2439,15 @@ public struct SessionChatSummary: Codable, Sendable {
/// read-only chats. Absence defaults to {@link ChatInteractivity.Full} for
/// backward compatibility.
public var interactivity: ChatInteractivity?
- /// Whether this chat has been archived independently of its owning session
- /// (see `chat/isArchivedChanged`).
- ///
- /// Generic clients use this to group or filter archived chats in session
- /// lists without subscribing to the session channel. Absence means the
- /// chat is not archived.
- public var archived: Bool?
+ /// Current chat status, matching {@link ChatSummary.status}.
+ ///
+ /// Includes the activity bits and the orthogonal {@link SessionStatus.IsRead}
+ /// and {@link SessionStatus.IsArchived} flags. Generic clients use these bits
+ /// to present read, unread, or archived chats in session lists without
+ /// subscribing to the session or chat channel. Absence means the host did
+ /// not provide the status; clients MUST treat it as unknown, not as unread
+ /// or unarchived.
+ public var status: SessionStatus?
/// Aggregate summary of file changes associated with this chat.
///
/// Servers may populate this so session lists can show per-chat change
@@ -2458,14 +2460,14 @@ public struct SessionChatSummary: Codable, Sendable {
title: String,
origin: ChatOrigin? = nil,
interactivity: ChatInteractivity? = nil,
- archived: Bool? = nil,
+ status: SessionStatus? = nil,
changes: ChangesSummary? = nil
) {
self.resource = resource
self.title = title
self.origin = origin
self.interactivity = interactivity
- self.archived = archived
+ self.status = status
self.changes = changes
}
}
diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift
index 6f4ef620c..865b9f9c1 100644
--- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift
+++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Reducers.swift
@@ -714,6 +714,11 @@ public func chatReducer(state: ChatState, action: StateAction) -> ChatState {
next.draft = a.draft
return next
+ case .chatIsReadChanged(let a):
+ var next = state
+ next.status = withStatusFlag(next.status, .isRead, a.isRead)
+ return next
+
case .chatIsArchivedChanged(let a):
var next = state
next.status = withStatusFlag(next.status, .isArchived, a.isArchived)
@@ -997,6 +1002,7 @@ public let clientDispatchableActions: Set = [
"chat/pendingMessageSet",
"chat/pendingMessageRemoved",
"chat/queuedMessagesReordered",
+ "chat/isReadChanged",
"chat/isArchivedChanged",
"chat/inputAnswerChanged",
"chat/inputCompleted",
@@ -1019,7 +1025,7 @@ public func isClientDispatchable(_ action: StateAction) -> Bool {
.sessionActiveClientRemoved,
.chatPendingMessageSet,
.chatPendingMessageRemoved, .chatQueuedMessagesReordered,
- .chatIsArchivedChanged,
+ .chatIsReadChanged, .chatIsArchivedChanged,
.chatInputAnswerChanged, .chatInputCompleted,
.sessionCustomizationToggled,
.sessionMcpServerStartRequested, .sessionMcpServerStopRequested,
diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift
index 1982a1e1d..882a0b4c5 100644
--- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift
+++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocolClient/Hosts/HostRuntime.swift
@@ -833,4 +833,5 @@ private func applySummaryChanges(
if let v = changes.project { existing.project = v }
if let v = changes.annotations { existing.annotations = v }
if let v = changes.workingDirectories { existing.workingDirectories = v }
+ if let v = changes.chats { existing.chats = v }
}
diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift
index beebff277..54e2395cf 100644
--- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift
+++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/ReducersTests.swift
@@ -115,6 +115,13 @@ final class ReducersTests: XCTestCase {
XCTAssertTrue(isClientDispatchable(action))
}
+ func testChatReadStateIsClientDispatchable() {
+ let action: StateAction = .chatIsReadChanged(
+ ChatIsReadChangedAction(type: .chatIsReadChanged, isRead: true)
+ )
+ XCTAssertTrue(isClientDispatchable(action))
+ }
+
func testClientDispatchableReturnsFalse() {
let action: StateAction = .sessionReady(SessionReadyAction(type: .sessionReady))
XCTAssertFalse(isClientDispatchable(action))
diff --git a/clients/typescript/src/client/hosts/runtime.ts b/clients/typescript/src/client/hosts/runtime.ts
index 26bfaa238..504924b56 100644
--- a/clients/typescript/src/client/hosts/runtime.ts
+++ b/clients/typescript/src/client/hosts/runtime.ts
@@ -890,5 +890,6 @@ function applySummaryChange(
if (changes.project !== undefined) merged.project = changes.project;
if (changes.workingDirectories !== undefined) merged.workingDirectories = changes.workingDirectories;
if (changes._meta !== undefined) merged._meta = changes._meta;
+ if (changes.chats !== undefined) merged.chats = changes.chats;
cache.set(params.session, merged);
}
diff --git a/clients/typescript/test/hosts.test.ts b/clients/typescript/test/hosts.test.ts
index 830564b0c..fe8ffd2e3 100644
--- a/clients/typescript/test/hosts.test.ts
+++ b/clients/typescript/test/hosts.test.ts
@@ -616,6 +616,55 @@ test('aggregatedSessions sorts by modifiedAt descending and tags hostLabel', asy
}
});
+test('sessionSummaryChanged replaces the compact chat status projection', async () => {
+ const initial = makeSummary('copilot:/s1', 'Session', 1_000);
+ initial.chats = [
+ { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle },
+ ];
+ const state: FakeHostState = makeFakeState({
+ sessions: [initial],
+ injectAfterInit: async server => {
+ await new Promise(r => setTimeout(r, 10));
+ const notif: JsonRpcNotification = {
+ jsonrpc: '2.0',
+ method: 'root/sessionSummaryChanged',
+ params: {
+ channel: ROOT,
+ session: initial.resource,
+ changes: {
+ chats: [
+ { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived },
+ ],
+ },
+ },
+ };
+ try {
+ await server.send(notif);
+ } catch {
+ // best-effort
+ }
+ },
+ });
+
+ const multi = new MultiHostClient();
+ try {
+ await multi.addHost({
+ id: 'read-state',
+ label: 'Read State',
+ transportFactory: makeBasicFactory(state),
+ });
+ await waitUntil(() =>
+ multi.aggregatedSessions()[0]?.summary.chats?.[0]?.status === (SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived)
+ );
+
+ assert.deepEqual(multi.aggregatedSessions()[0]?.summary.chats, [
+ { resource: 'ahp-chat:/default', title: 'Default', status: SessionStatus.Idle | SessionStatus.IsRead | SessionStatus.IsArchived },
+ ]);
+ } finally {
+ await multi.shutdown();
+ }
+});
+
test('aggregatedAgents tags every agent with its host', async () => {
const multi = new MultiHostClient();
try {
diff --git a/docs/.changes/20260929-session-chat-summary-archived.json b/docs/.changes/20260929-session-chat-summary-archived.json
index 20a9f0901..208a962fb 100644
--- a/docs/.changes/20260929-session-chat-summary-archived.json
+++ b/docs/.changes/20260929-session-chat-summary-archived.json
@@ -1,4 +1,4 @@
{
"type": "added",
- "message": "`SessionChatSummary.archived` exposes per-chat archived state in the lightweight chat catalog without requiring a session subscription."
+ "message": "`SessionChatSummary.status` exposes per-chat archived state via `SessionStatus.IsArchived` in the lightweight chat catalog without requiring a session subscription."
}
diff --git a/docs/.changes/20261001-chat-read-state-action.json b/docs/.changes/20261001-chat-read-state-action.json
new file mode 100644
index 000000000..f03094090
--- /dev/null
+++ b/docs/.changes/20261001-chat-read-state-action.json
@@ -0,0 +1,4 @@
+{
+ "type": "added",
+ "message": "`chat/isReadChanged` action and optional `SessionChatSummary.status` projection for independently tracking the status of any known chat, including the default chat; the status bitset replaces the unreleased `isRead` and `archived` catalog fields."
+}
diff --git a/docs/guide/actions.md b/docs/guide/actions.md
index d592fa1f8..2b793756a 100644
--- a/docs/guide/actions.md
+++ b/docs/guide/actions.md
@@ -105,6 +105,7 @@ Tool calls follow a discriminated-union state machine — see [State Model — T
| `session/changesetsChanged` | No | The catalog of changesets the host advertises for this session changed (full replacement) |
| `chat/changesetsChanged` | No | The catalog of changesets the host advertises for this chat changed (full replacement) |
| `session/isReadChanged` | **Yes** | Client marked session as read or unread |
+| `chat/isReadChanged` | **Yes** | Client marked any known chat, including the default chat, as read or unread |
| `session/isArchivedChanged` | **Yes** | Client archived or unarchived session |
| `session/configChanged` | **Yes** | Mutable session config values changed |
| `session/metaChanged` | No | The session's `_meta` side-channel was replaced |
@@ -214,6 +215,7 @@ The client applies the action **optimistically** to its local state before sendi
| `chat/queuedMessagesReordered` | Reorders queued messages; unknown IDs ignored, unmentioned messages kept at end |
| `session/customizationToggled` | Replaces a customization's explicit enablement decisions by id |
| `session/isReadChanged` | Marks the session as read or unread |
+| `chat/isReadChanged` | Marks any known chat, including the default chat, as read or unread without changing its owning session or sibling chats; refreshes the compact `SessionChatSummary.status` projection |
| `session/isArchivedChanged` | Archives or unarchives the session |
## Reducers
diff --git a/docs/guide/state-model.md b/docs/guide/state-model.md
index 9800d70c1..2d698d4a7 100644
--- a/docs/guide/state-model.md
+++ b/docs/guide/state-model.md
@@ -112,6 +112,7 @@ SessionSummary {
workingDirectories?: URI[] // equal-peer working directories
annotations?: AnnotationsSummary
changes?: ChangesSummary
+ chats?: SessionChatSummary[] // compact list presentation, including per-chat status
}
ProjectInfo {
@@ -120,6 +121,14 @@ ProjectInfo {
}
```
+`SessionChatSummary.status` is the same `SessionStatus` bitset as
+`ChatSummary.status`, including activity, read, and archived state. Hosts keep
+both projections synchronized with the chat's state so session lists can
+render per-chat status without subscribing to every session or chat. Clients
+check `SessionStatus.IsRead` and `SessionStatus.IsArchived` with bitwise
+operations rather than separate boolean fields. The compact `status` field
+is optional to ease adoption; absence means unknown, not unread or unarchived.
+
The `status` bitset encodes both the session's activity state and metadata flags like read/archived state. See the [Session Status Bitset](#session-status-bitset) table below for details.
### Session Status Bitset
@@ -132,11 +141,16 @@ The `status` bitset encodes both the session's activity state and metadata flags
| `SessionStatus.Error` | `2` | `1 << 1` | The most recent turn ended with an error. |
| `SessionStatus.InProgress` | `8` | `1 << 3` | A turn is active. |
| `SessionStatus.InputNeeded` | `24` | `(1 << 3) \| (1 << 4)` | A turn is active and either at least one user input request is open, or at least one tool call is awaiting user confirmation (pre- or post-execution). Includes the `InProgress` bit. |
-| `SessionStatus.IsRead` | `32` | `1 << 5` | The client has viewed this session since its last modification. Cleared automatically when a new turn starts or an input request arrives. Toggled via `session/isReadChanged`. |
+| `SessionStatus.IsRead` | `32` | `1 << 5` | The client has viewed this session or chat since its last modification. Cleared automatically when a new turn starts or an input request arrives. Toggled via `session/isReadChanged` or `chat/isReadChanged` on the corresponding channel. |
| `SessionStatus.IsArchived` | `64` | `1 << 6` | The session has been archived by the client. Toggled via `session/isArchivedChanged`. |
Bits 0–4 encode mutually-exclusive **activity** status (exactly one is set at a time). Bits 5+ encode orthogonal **metadata** flags that may be combined with any activity status via bitwise OR.
+Read state is scoped to the addressed channel. `chat/isReadChanged` changes any
+known chat, including a default chat, without changing its owning session or
+sibling chats. `session/isReadChanged` changes only the session's independent
+read state.
+
For example, `(status & SessionStatus.InProgress) !== 0` is true for both `InProgress` and `InputNeeded`. A session that is idle, read, and archived has status `1 | 32 | 64 = 97`.
## Chat State
diff --git a/docs/specification/chat-channel.md b/docs/specification/chat-channel.md
index 132d431db..469908f52 100644
--- a/docs/specification/chat-channel.md
+++ b/docs/specification/chat-channel.md
@@ -23,6 +23,18 @@ Multiple chat channels may be active simultaneously. Clients subscribe to each c
Subscribers receive a [`ChatState`](/reference/chat#chatstate) snapshot. `ChatState` denormalizes the [`ChatSummary`](/reference/chat#chatsummary) fields directly onto itself (`resource`, `title`, `status`, `activity`, `modifiedAt`, `origin`, `workingDirectories`) and adds the conversation contents (history of completed turns, the active turn if any, pending messages, outstanding input requests, and the user's in-progress [`draft`](#drafts)) plus the optional [`changesets`](#per-chat-changesets) catalogue. Producers MUST keep the chat's `ChatSummary` in the session catalog consistent with these inlined summary fields — typically by dispatching a matching [`session/chatUpdated`](/reference/session#actions) whenever any summary field on the chat changes. Refer to the [State Model guide](/guide/state-model) for a structural overview.
+Clients mark any known chat, including the owning session's default chat, as
+read or unread by dispatching
+[`chat/isReadChanged`](/reference/chat#actions) on that chat's channel. The
+action toggles `SessionStatus.IsRead` only on the addressed `ChatState`; it
+does not change the read state of the owning session or sibling chats. The host
+echoes the accepted action in server order and keeps the corresponding
+`ChatSummary.status` synchronized through `session/chatUpdated` and the
+matching compact `SessionChatSummary.status` projection synchronized through
+`root/sessionSummaryChanged`.
+`session/isReadChanged` independently changes the owning session's read state;
+neither action implies the other.
+
When a client subscribes with `view.turns`, the server MAY expose only a tail of
the most recent completed turns in the initial snapshot. The requested number is
advisory: the server MAY return more or fewer turns than requested. If
diff --git a/docs/specification/session-channel.md b/docs/specification/session-channel.md
index f35e4693f..2631b22b1 100644
--- a/docs/specification/session-channel.md
+++ b/docs/specification/session-channel.md
@@ -65,6 +65,15 @@ per-chat channel stay consistent. Cross-session moves use
`session/chatRemoved` on the previous owner and `session/chatAdded` on the new
owner. Same-session moves only change the selected catalog entry's position.
+When a chat's `status` changes, the producer MUST project its
+exact value into the matching
+[`SessionChatSummary.status`](/reference/session#sessionchatsummary) field and
+publish the complete compact `SessionSummary.chats` catalog through
+`root/sessionSummaryChanged`. The bitset includes the chat's activity state and
+its independent `SessionStatus.IsRead` and `SessionStatus.IsArchived` flags;
+clients use bitwise checks to render these states. The compact `status` field
+is optional to ease adoption. When it is absent, clients MUST treat the status
+as unknown, not as unread or unarchived.
When `defaultChat` is set, its matching `ChatSummary` MUST NOT advertise
`movable: true`. If changing `defaultChat` changes either the old or new
diff --git a/schema/actions.schema.json b/schema/actions.schema.json
index 56571ab87..23cfbf2d6 100644
--- a/schema/actions.schema.json
+++ b/schema/actions.schema.json
@@ -192,7 +192,7 @@
},
"SessionChatUpdatedAction": {
"type": "object",
- "description": "One existing chat's summary fields changed.\n\nPartial-update semantics: only fields present in `changes` are written;\nomitted fields are preserved. Identity fields (`resource`) MUST NOT be\ncarried in `changes`. No-op when no entry with `chat` exists — clients\nSHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.\n\nMirrors the root-channel `root/sessionSummaryChanged` notification.",
+ "description": "One existing chat's summary fields changed.\n\nPartial-update semantics: only fields present in `changes` are written;\nomitted fields are preserved. Identity fields (`resource`) MUST NOT be\ncarried in `changes`. No-op when no entry with `chat` exists — clients\nSHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.\n\nMirrors the root-channel `root/sessionSummaryChanged` notification.\nWhen `changes.status` changes, the host MUST project that exact value into\nthe matching `SessionChatSummary.status` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.",
"properties": {
"type": {
"const": "session/chatUpdated"
@@ -1655,6 +1655,23 @@
"type"
]
},
+ "ChatIsReadChangedAction": {
+ "type": "object",
+ "description": "The read state of the chat changed.\n\nDispatched by a client to mark any known chat, including the owning\nsession's default chat, as read (e.g. after viewing it) or unread. This\nchanges only the addressed chat; it does not change the read state of its\nowning session or sibling chats. Use `session/isReadChanged` only to change\nthe owning session's independent read state. After accepting this action,\nthe host also synchronizes the addressed chat's `ChatSummary.status` and\n`SessionChatSummary.status` projections.",
+ "properties": {
+ "type": {
+ "const": "chat/isReadChanged"
+ },
+ "isRead": {
+ "type": "boolean",
+ "description": "Whether the chat has been read"
+ }
+ },
+ "required": [
+ "type",
+ "isRead"
+ ]
+ },
"ChatIsArchivedChangedAction": {
"type": "object",
"description": "The archived state of the chat changed.\n\nDispatched by a client to archive a chat independently of its owning\nsession or to restore it. Archiving the session's default chat is equivalent\nto archiving the session and SHOULD use `session/isArchivedChanged` instead.",
@@ -2582,6 +2599,9 @@
{
"$ref": "#/$defs/ChatDraftChangedAction"
},
+ {
+ "$ref": "#/$defs/ChatIsReadChangedAction"
+ },
{
"$ref": "#/$defs/ChatIsArchivedChangedAction"
},
@@ -3745,9 +3765,9 @@
"$ref": "#/$defs/ChatInteractivity",
"description": "How the user can interact with this chat.\n\nGeneric clients use this to omit hidden chats and disable input for\nread-only chats. Absence defaults to {@link ChatInteractivity.Full} for\nbackward compatibility."
},
- "archived": {
- "type": "boolean",
- "description": "Whether this chat has been archived independently of its owning session\n(see `chat/isArchivedChanged`).\n\nGeneric clients use this to group or filter archived chats in session\nlists without subscribing to the session channel. Absence means the\nchat is not archived."
+ "status": {
+ "$ref": "#/$defs/SessionStatus",
+ "description": "Current chat status, matching {@link ChatSummary.status}.\n\nIncludes the activity bits and the orthogonal {@link SessionStatus.IsRead}\nand {@link SessionStatus.IsArchived} flags. Generic clients use these bits\nto present read, unread, or archived chats in session lists without\nsubscribing to the session or chat channel. Absence means the host did\nnot provide the status; clients MUST treat it as unknown, not as unread\nor unarchived."
},
"changes": {
"$ref": "#/$defs/ChangesSummary",
@@ -9187,6 +9207,9 @@
{
"$ref": "#/$defs/ChatDraftChangedAction"
},
+ {
+ "$ref": "#/$defs/ChatIsReadChangedAction"
+ },
{
"$ref": "#/$defs/ChatIsArchivedChangedAction"
},
diff --git a/schema/commands.schema.json b/schema/commands.schema.json
index 3ecfad133..907789a41 100644
--- a/schema/commands.schema.json
+++ b/schema/commands.schema.json
@@ -2980,9 +2980,9 @@
"$ref": "#/$defs/ChatInteractivity",
"description": "How the user can interact with this chat.\n\nGeneric clients use this to omit hidden chats and disable input for\nread-only chats. Absence defaults to {@link ChatInteractivity.Full} for\nbackward compatibility."
},
- "archived": {
- "type": "boolean",
- "description": "Whether this chat has been archived independently of its owning session\n(see `chat/isArchivedChanged`).\n\nGeneric clients use this to group or filter archived chats in session\nlists without subscribing to the session channel. Absence means the\nchat is not archived."
+ "status": {
+ "$ref": "#/$defs/SessionStatus",
+ "description": "Current chat status, matching {@link ChatSummary.status}.\n\nIncludes the activity bits and the orthogonal {@link SessionStatus.IsRead}\nand {@link SessionStatus.IsArchived} flags. Generic clients use these bits\nto present read, unread, or archived chats in session lists without\nsubscribing to the session or chat channel. Absence means the host did\nnot provide the status; clients MUST treat it as unknown, not as unread\nor unarchived."
},
"changes": {
"$ref": "#/$defs/ChangesSummary",
@@ -7847,7 +7847,7 @@
},
"SessionChatUpdatedAction": {
"type": "object",
- "description": "One existing chat's summary fields changed.\n\nPartial-update semantics: only fields present in `changes` are written;\nomitted fields are preserved. Identity fields (`resource`) MUST NOT be\ncarried in `changes`. No-op when no entry with `chat` exists — clients\nSHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.\n\nMirrors the root-channel `root/sessionSummaryChanged` notification.",
+ "description": "One existing chat's summary fields changed.\n\nPartial-update semantics: only fields present in `changes` are written;\nomitted fields are preserved. Identity fields (`resource`) MUST NOT be\ncarried in `changes`. No-op when no entry with `chat` exists — clients\nSHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.\n\nMirrors the root-channel `root/sessionSummaryChanged` notification.\nWhen `changes.status` changes, the host MUST project that exact value into\nthe matching `SessionChatSummary.status` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.",
"properties": {
"type": {
"const": "session/chatUpdated"
@@ -9310,6 +9310,23 @@
"type"
]
},
+ "ChatIsReadChangedAction": {
+ "type": "object",
+ "description": "The read state of the chat changed.\n\nDispatched by a client to mark any known chat, including the owning\nsession's default chat, as read (e.g. after viewing it) or unread. This\nchanges only the addressed chat; it does not change the read state of its\nowning session or sibling chats. Use `session/isReadChanged` only to change\nthe owning session's independent read state. After accepting this action,\nthe host also synchronizes the addressed chat's `ChatSummary.status` and\n`SessionChatSummary.status` projections.",
+ "properties": {
+ "type": {
+ "const": "chat/isReadChanged"
+ },
+ "isRead": {
+ "type": "boolean",
+ "description": "Whether the chat has been read"
+ }
+ },
+ "required": [
+ "type",
+ "isRead"
+ ]
+ },
"ChatIsArchivedChangedAction": {
"type": "object",
"description": "The archived state of the chat changed.\n\nDispatched by a client to archive a chat independently of its owning\nsession or to restore it. Archiving the session's default chat is equivalent\nto archiving the session and SHOULD use `session/isArchivedChanged` instead.",
@@ -10326,6 +10343,9 @@
{
"$ref": "#/$defs/ChatDraftChangedAction"
},
+ {
+ "$ref": "#/$defs/ChatIsReadChangedAction"
+ },
{
"$ref": "#/$defs/ChatIsArchivedChangedAction"
},
diff --git a/schema/errors.schema.json b/schema/errors.schema.json
index 536be4dc4..26373f3b0 100644
--- a/schema/errors.schema.json
+++ b/schema/errors.schema.json
@@ -1243,9 +1243,9 @@
"$ref": "#/$defs/ChatInteractivity",
"description": "How the user can interact with this chat.\n\nGeneric clients use this to omit hidden chats and disable input for\nread-only chats. Absence defaults to {@link ChatInteractivity.Full} for\nbackward compatibility."
},
- "archived": {
- "type": "boolean",
- "description": "Whether this chat has been archived independently of its owning session\n(see `chat/isArchivedChanged`).\n\nGeneric clients use this to group or filter archived chats in session\nlists without subscribing to the session channel. Absence means the\nchat is not archived."
+ "status": {
+ "$ref": "#/$defs/SessionStatus",
+ "description": "Current chat status, matching {@link ChatSummary.status}.\n\nIncludes the activity bits and the orthogonal {@link SessionStatus.IsRead}\nand {@link SessionStatus.IsArchived} flags. Generic clients use these bits\nto present read, unread, or archived chats in session lists without\nsubscribing to the session or chat channel. Absence means the host did\nnot provide the status; clients MUST treat it as unknown, not as unread\nor unarchived."
},
"changes": {
"$ref": "#/$defs/ChangesSummary",
@@ -8617,6 +8617,9 @@
{
"$ref": "#/$defs/ChatDraftChangedAction"
},
+ {
+ "$ref": "#/$defs/ChatIsReadChangedAction"
+ },
{
"$ref": "#/$defs/ChatIsArchivedChangedAction"
},
@@ -9008,7 +9011,7 @@
},
"SessionChatUpdatedAction": {
"type": "object",
- "description": "One existing chat's summary fields changed.\n\nPartial-update semantics: only fields present in `changes` are written;\nomitted fields are preserved. Identity fields (`resource`) MUST NOT be\ncarried in `changes`. No-op when no entry with `chat` exists — clients\nSHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.\n\nMirrors the root-channel `root/sessionSummaryChanged` notification.",
+ "description": "One existing chat's summary fields changed.\n\nPartial-update semantics: only fields present in `changes` are written;\nomitted fields are preserved. Identity fields (`resource`) MUST NOT be\ncarried in `changes`. No-op when no entry with `chat` exists — clients\nSHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.\n\nMirrors the root-channel `root/sessionSummaryChanged` notification.\nWhen `changes.status` changes, the host MUST project that exact value into\nthe matching `SessionChatSummary.status` field and publish\nthe updated compact chat catalog through `root/sessionSummaryChanged`.",
"properties": {
"type": {
"const": "session/chatUpdated"
@@ -10320,6 +10323,23 @@
"type"
]
},
+ "ChatIsReadChangedAction": {
+ "type": "object",
+ "description": "The read state of the chat changed.\n\nDispatched by a client to mark any known chat, including the owning\nsession's default chat, as read (e.g. after viewing it) or unread. This\nchanges only the addressed chat; it does not change the read state of its\nowning session or sibling chats. Use `session/isReadChanged` only to change\nthe owning session's independent read state. After accepting this action,\nthe host also synchronizes the addressed chat's `ChatSummary.status` and\n`SessionChatSummary.status` projections.",
+ "properties": {
+ "type": {
+ "const": "chat/isReadChanged"
+ },
+ "isRead": {
+ "type": "boolean",
+ "description": "Whether the chat has been read"
+ }
+ },
+ "required": [
+ "type",
+ "isRead"
+ ]
+ },
"ChatIsArchivedChangedAction": {
"type": "object",
"description": "The archived state of the chat changed.\n\nDispatched by a client to archive a chat independently of its owning\nsession or to restore it. Archiving the session's default chat is equivalent\nto archiving the session and SHOULD use `session/isArchivedChanged` instead.",
diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json
index c43b4d93d..e5685d233 100644
--- a/schema/notifications.schema.json
+++ b/schema/notifications.schema.json
@@ -146,7 +146,7 @@
"description": "Chat that receives input when none is selected, independent of catalog position."
}
},
- "description": "Mutable summary fields that changed; omitted fields are unchanged.\n\nIdentity fields (`resource`, `provider`, `createdAt`) never change and\nMUST be omitted by senders; receivers SHOULD ignore them if present."
+ "description": "Mutable summary fields that changed; omitted fields are unchanged.\n\nIdentity fields (`resource`, `provider`, `createdAt`) never change and\nMUST be omitted by senders; receivers SHOULD ignore them if present.\nWhen `chats` is present, it replaces the complete compact chat catalog."
}
},
"required": [
@@ -1421,9 +1421,9 @@
"$ref": "#/$defs/ChatInteractivity",
"description": "How the user can interact with this chat.\n\nGeneric clients use this to omit hidden chats and disable input for\nread-only chats. Absence defaults to {@link ChatInteractivity.Full} for\nbackward compatibility."
},
- "archived": {
- "type": "boolean",
- "description": "Whether this chat has been archived independently of its owning session\n(see `chat/isArchivedChanged`).\n\nGeneric clients use this to group or filter archived chats in session\nlists without subscribing to the session channel. Absence means the\nchat is not archived."
+ "status": {
+ "$ref": "#/$defs/SessionStatus",
+ "description": "Current chat status, matching {@link ChatSummary.status}.\n\nIncludes the activity bits and the orthogonal {@link SessionStatus.IsRead}\nand {@link SessionStatus.IsArchived} flags. Generic clients use these bits\nto present read, unread, or archived chats in session lists without\nsubscribing to the session or chat channel. Absence means the host did\nnot provide the status; clients MUST treat it as unknown, not as unread\nor unarchived."
},
"changes": {
"$ref": "#/$defs/ChangesSummary",
diff --git a/schema/state.schema.json b/schema/state.schema.json
index 335969bc1..058febd69 100644
--- a/schema/state.schema.json
+++ b/schema/state.schema.json
@@ -1154,9 +1154,9 @@
"$ref": "#/$defs/ChatInteractivity",
"description": "How the user can interact with this chat.\n\nGeneric clients use this to omit hidden chats and disable input for\nread-only chats. Absence defaults to {@link ChatInteractivity.Full} for\nbackward compatibility."
},
- "archived": {
- "type": "boolean",
- "description": "Whether this chat has been archived independently of its owning session\n(see `chat/isArchivedChanged`).\n\nGeneric clients use this to group or filter archived chats in session\nlists without subscribing to the session channel. Absence means the\nchat is not archived."
+ "status": {
+ "$ref": "#/$defs/SessionStatus",
+ "description": "Current chat status, matching {@link ChatSummary.status}.\n\nIncludes the activity bits and the orthogonal {@link SessionStatus.IsRead}\nand {@link SessionStatus.IsArchived} flags. Generic clients use these bits\nto present read, unread, or archived chats in session lists without\nsubscribing to the session or chat channel. Absence means the host did\nnot provide the status; clients MUST treat it as unknown, not as unread\nor unarchived."
},
"changes": {
"$ref": "#/$defs/ChangesSummary",
diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts
index e28c83086..1edeeafb9 100644
--- a/scripts/generate-csharp.ts
+++ b/scripts/generate-csharp.ts
@@ -1669,6 +1669,7 @@ const ACTION_VARIANTS: { type: string; variantName: string; tsInterface: string
{ type: 'chat/pendingMessageRemoved', variantName: 'ChatPendingMessageRemoved', tsInterface: 'ChatPendingMessageRemovedAction' },
{ type: 'chat/queuedMessagesReordered', variantName: 'ChatQueuedMessagesReordered', tsInterface: 'ChatQueuedMessagesReorderedAction' },
{ type: 'chat/draftChanged', variantName: 'ChatDraftChanged', tsInterface: 'ChatDraftChangedAction' },
+ { type: 'chat/isReadChanged', variantName: 'ChatIsReadChanged', tsInterface: 'ChatIsReadChangedAction' },
{ type: 'chat/isArchivedChanged', variantName: 'ChatIsArchivedChanged', tsInterface: 'ChatIsArchivedChangedAction' },
{ type: 'chat/inputRequested', variantName: 'ChatInputRequested', tsInterface: 'ChatInputRequestedAction' },
{ type: 'chat/inputAnswerChanged', variantName: 'ChatInputAnswerChanged', tsInterface: 'ChatInputAnswerChangedAction' },
diff --git a/scripts/generate-go.ts b/scripts/generate-go.ts
index 6a0e78ce8..55c415ec3 100644
--- a/scripts/generate-go.ts
+++ b/scripts/generate-go.ts
@@ -1590,6 +1590,7 @@ const ACTION_VARIANTS: {
{ type: 'chat/pendingMessageRemoved', variantName: 'ChatPendingMessageRemoved', tsInterface: 'ChatPendingMessageRemovedAction' },
{ type: 'chat/queuedMessagesReordered', variantName: 'ChatQueuedMessagesReordered', tsInterface: 'ChatQueuedMessagesReorderedAction' },
{ type: 'chat/draftChanged', variantName: 'ChatDraftChanged', tsInterface: 'ChatDraftChangedAction' },
+ { type: 'chat/isReadChanged', variantName: 'ChatIsReadChanged', tsInterface: 'ChatIsReadChangedAction' },
{ type: 'chat/isArchivedChanged', variantName: 'ChatIsArchivedChanged', tsInterface: 'ChatIsArchivedChangedAction' },
{ type: 'chat/inputRequested', variantName: 'ChatInputRequested', tsInterface: 'ChatInputRequestedAction' },
{ type: 'chat/inputAnswerChanged', variantName: 'ChatInputAnswerChanged', tsInterface: 'ChatInputAnswerChangedAction' },
diff --git a/scripts/generate-kotlin.ts b/scripts/generate-kotlin.ts
index 4594663cb..ddbea2b58 100644
--- a/scripts/generate-kotlin.ts
+++ b/scripts/generate-kotlin.ts
@@ -1544,6 +1544,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[]
{ type: 'chat/pendingMessageRemoved', caseName: 'ChatPendingMessageRemoved', tsInterface: 'ChatPendingMessageRemovedAction' },
{ type: 'chat/queuedMessagesReordered', caseName: 'ChatQueuedMessagesReordered', tsInterface: 'ChatQueuedMessagesReorderedAction' },
{ type: 'chat/draftChanged', caseName: 'ChatDraftChanged', tsInterface: 'ChatDraftChangedAction' },
+ { type: 'chat/isReadChanged', caseName: 'ChatIsReadChanged', tsInterface: 'ChatIsReadChangedAction' },
{ type: 'chat/isArchivedChanged', caseName: 'ChatIsArchivedChanged', tsInterface: 'ChatIsArchivedChangedAction' },
{ type: 'chat/inputRequested', caseName: 'ChatInputRequested', tsInterface: 'ChatInputRequestedAction' },
{ type: 'chat/inputAnswerChanged', caseName: 'ChatInputAnswerChanged', tsInterface: 'ChatInputAnswerChangedAction' },
diff --git a/scripts/generate-rust.ts b/scripts/generate-rust.ts
index 22eda3358..a9e21eb54 100644
--- a/scripts/generate-rust.ts
+++ b/scripts/generate-rust.ts
@@ -1561,6 +1561,7 @@ const ACTION_VARIANTS: {
{ type: 'chat/pendingMessageRemoved', variantName: 'ChatPendingMessageRemoved', tsInterface: 'ChatPendingMessageRemovedAction' },
{ type: 'chat/queuedMessagesReordered', variantName: 'ChatQueuedMessagesReordered', tsInterface: 'ChatQueuedMessagesReorderedAction' },
{ type: 'chat/draftChanged', variantName: 'ChatDraftChanged', tsInterface: 'ChatDraftChangedAction' },
+ { type: 'chat/isReadChanged', variantName: 'ChatIsReadChanged', tsInterface: 'ChatIsReadChangedAction' },
{ type: 'chat/isArchivedChanged', variantName: 'ChatIsArchivedChanged', tsInterface: 'ChatIsArchivedChangedAction' },
{ type: 'chat/inputRequested', variantName: 'ChatInputRequested', tsInterface: 'ChatInputRequestedAction' },
{ type: 'chat/inputAnswerChanged', variantName: 'ChatInputAnswerChanged', tsInterface: 'ChatInputAnswerChangedAction' },
diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts
index 584d16bb3..86bdc9988 100644
--- a/scripts/generate-swift.ts
+++ b/scripts/generate-swift.ts
@@ -1442,6 +1442,7 @@ const ACTION_VARIANTS: { type: string; caseName: string; tsInterface: string }[]
{ type: 'chat/pendingMessageRemoved', caseName: 'chatPendingMessageRemoved', tsInterface: 'ChatPendingMessageRemovedAction' },
{ type: 'chat/queuedMessagesReordered', caseName: 'chatQueuedMessagesReordered', tsInterface: 'ChatQueuedMessagesReorderedAction' },
{ type: 'chat/draftChanged', caseName: 'chatDraftChanged', tsInterface: 'ChatDraftChangedAction' },
+ { type: 'chat/isReadChanged', caseName: 'chatIsReadChanged', tsInterface: 'ChatIsReadChangedAction' },
{ type: 'chat/isArchivedChanged', caseName: 'chatIsArchivedChanged', tsInterface: 'ChatIsArchivedChangedAction' },
{ type: 'chat/inputRequested', caseName: 'chatInputRequested', tsInterface: 'ChatInputRequestedAction' },
{ type: 'chat/inputAnswerChanged', caseName: 'chatInputAnswerChanged', tsInterface: 'ChatInputAnswerChangedAction' },
diff --git a/types/action-origin.generated.ts b/types/action-origin.generated.ts
index b3a70ac6b..6cd61e2a5 100644
--- a/types/action-origin.generated.ts
+++ b/types/action-origin.generated.ts
@@ -66,6 +66,7 @@ import type {
ChatPendingMessageRemovedAction,
ChatQueuedMessagesReorderedAction,
ChatDraftChangedAction,
+ ChatIsReadChangedAction,
ChatIsArchivedChangedAction,
ChatInputRequestedAction,
ChatInputAnswerChangedAction,
@@ -235,6 +236,7 @@ export type ChatAction =
| ChatPendingMessageRemovedAction
| ChatQueuedMessagesReorderedAction
| ChatDraftChangedAction
+ | ChatIsReadChangedAction
| ChatIsArchivedChangedAction
| ChatInputRequestedAction
| ChatInputAnswerChangedAction
@@ -258,6 +260,7 @@ export type ClientChatAction =
| ChatPendingMessageRemovedAction
| ChatQueuedMessagesReorderedAction
| ChatDraftChangedAction
+ | ChatIsReadChangedAction
| ChatIsArchivedChangedAction
| ChatInputAnswerChangedAction
| ChatInputCompletedAction
@@ -498,6 +501,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool
[ActionType.ChatPendingMessageRemoved]: true,
[ActionType.ChatQueuedMessagesReordered]: true,
[ActionType.ChatDraftChanged]: true,
+ [ActionType.ChatIsReadChanged]: true,
[ActionType.ChatIsArchivedChanged]: true,
[ActionType.ChatInputRequested]: false,
[ActionType.ChatInputAnswerChanged]: true,
diff --git a/types/channels-chat/actions.ts b/types/channels-chat/actions.ts
index 692a1ad3f..8102468b1 100644
--- a/types/channels-chat/actions.ts
+++ b/types/channels-chat/actions.ts
@@ -836,6 +836,27 @@ export interface ChatDraftChangedAction {
draft?: Message;
}
+/**
+ * The read state of the chat changed.
+ *
+ * Dispatched by a client to mark any known chat, including the owning
+ * session's default chat, as read (e.g. after viewing it) or unread. This
+ * changes only the addressed chat; it does not change the read state of its
+ * owning session or sibling chats. Use `session/isReadChanged` only to change
+ * the owning session's independent read state. After accepting this action,
+ * the host also synchronizes the addressed chat's `ChatSummary.status` and
+ * `SessionChatSummary.status` projections.
+ *
+ * @category Chat Actions
+ * @version 1
+ * @clientDispatchable
+ */
+export interface ChatIsReadChangedAction {
+ type: ActionType.ChatIsReadChanged;
+ /** Whether the chat has been read */
+ isRead: boolean;
+}
+
/**
* The archived state of the chat changed.
*
@@ -944,6 +965,7 @@ export type ChatAction =
| ChatPendingMessageRemovedAction
| ChatQueuedMessagesReorderedAction
| ChatDraftChangedAction
+ | ChatIsReadChangedAction
| ChatIsArchivedChangedAction
| ChatInputRequestedAction
| ChatInputAnswerChangedAction
diff --git a/types/channels-chat/reducer.ts b/types/channels-chat/reducer.ts
index 1cf16f187..6a9860801 100644
--- a/types/channels-chat/reducer.ts
+++ b/types/channels-chat/reducer.ts
@@ -951,6 +951,9 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st
case ActionType.ChatDraftChanged:
return { ...state, draft: action.draft };
+ case ActionType.ChatIsReadChanged:
+ return { ...state, status: withStatusFlag(state.status, SessionStatus.IsRead, action.isRead) };
+
case ActionType.ChatIsArchivedChanged:
return { ...state, status: withStatusFlag(state.status, SessionStatus.IsArchived, action.isArchived) };
diff --git a/types/channels-root/notifications.ts b/types/channels-root/notifications.ts
index eb74fedc5..e658d5b87 100644
--- a/types/channels-root/notifications.ts
+++ b/types/channels-root/notifications.ts
@@ -139,6 +139,7 @@ export interface SessionSummaryChangedParams {
*
* Identity fields (`resource`, `provider`, `createdAt`) never change and
* MUST be omitted by senders; receivers SHOULD ignore them if present.
+ * When `chats` is present, it replaces the complete compact chat catalog.
*/
changes: Partial;
}
diff --git a/types/channels-session/actions.ts b/types/channels-session/actions.ts
index d6d2ad20b..9e54a3319 100644
--- a/types/channels-session/actions.ts
+++ b/types/channels-session/actions.ts
@@ -81,6 +81,9 @@ export interface SessionChatRemovedAction {
* SHOULD then wait for a {@link SessionChatAddedAction | `session/chatAdded`}.
*
* Mirrors the root-channel `root/sessionSummaryChanged` notification.
+ * When `changes.status` changes, the host MUST project that exact value into
+ * the matching `SessionChatSummary.status` field and publish
+ * the updated compact chat catalog through `root/sessionSummaryChanged`.
*
* @category Session Actions
* @version 1
diff --git a/types/channels-session/state.ts b/types/channels-session/state.ts
index cb3946dd8..d2e964118 100644
--- a/types/channels-session/state.ts
+++ b/types/channels-session/state.ts
@@ -544,14 +544,16 @@ export interface SessionChatSummary {
*/
interactivity?: ChatInteractivity;
/**
- * Whether this chat has been archived independently of its owning session
- * (see `chat/isArchivedChanged`).
+ * Current chat status, matching {@link ChatSummary.status}.
*
- * Generic clients use this to group or filter archived chats in session
- * lists without subscribing to the session channel. Absence means the
- * chat is not archived.
+ * Includes the activity bits and the orthogonal {@link SessionStatus.IsRead}
+ * and {@link SessionStatus.IsArchived} flags. Generic clients use these bits
+ * to present read, unread, or archived chats in session lists without
+ * subscribing to the session or chat channel. Absence means the host did
+ * not provide the status; clients MUST treat it as unknown, not as unread
+ * or unarchived.
*/
- archived?: boolean;
+ status?: SessionStatus;
/**
* Aggregate summary of file changes associated with this chat.
*
diff --git a/types/common/actions.ts b/types/common/actions.ts
index 14c462417..36d0a6579 100644
--- a/types/common/actions.ts
+++ b/types/common/actions.ts
@@ -78,6 +78,7 @@ import type {
ChatPendingMessageRemovedAction,
ChatQueuedMessagesReorderedAction,
ChatDraftChangedAction,
+ ChatIsReadChangedAction,
ChatIsArchivedChangedAction,
ChatInputRequestedAction,
ChatInputAnswerChangedAction,
@@ -192,6 +193,7 @@ export const enum ActionType {
ChatPendingMessageRemoved = 'chat/pendingMessageRemoved',
ChatQueuedMessagesReordered = 'chat/queuedMessagesReordered',
ChatDraftChanged = 'chat/draftChanged',
+ ChatIsReadChanged = 'chat/isReadChanged',
ChatIsArchivedChanged = 'chat/isArchivedChanged',
ChatInputRequested = 'chat/inputRequested',
ChatInputAnswerChanged = 'chat/inputAnswerChanged',
@@ -347,6 +349,7 @@ export type StateAction =
| ChatPendingMessageRemovedAction
| ChatQueuedMessagesReorderedAction
| ChatDraftChangedAction
+ | ChatIsReadChangedAction
| ChatIsArchivedChangedAction
| ChatInputRequestedAction
| ChatInputAnswerChangedAction
diff --git a/types/reducers.test.ts b/types/reducers.test.ts
index cc90200a3..20574e2ba 100644
--- a/types/reducers.test.ts
+++ b/types/reducers.test.ts
@@ -34,6 +34,7 @@ import { ActionType } from './actions.js';
import type { RootState, SessionState, ChatState, TerminalState, ChangesetState, AnnotationsState, ResourceWatchState, AutomationState, AutomationRunState } from './state.js';
import {
SessionStatus,
+ SessionLifecycle,
TurnState,
MessageKind,
} from './state.js';
@@ -212,6 +213,49 @@ describe('isClientDispatchable', () => {
});
});
+describe('chat read state scoping', () => {
+ it('changes the default chat without changing its owning session or sibling chat', () => {
+ const defaultChat: ChatState = {
+ resource: 'ahp-chat:/default',
+ title: 'Default Chat',
+ status: SessionStatus.Idle,
+ modifiedAt: '2026-10-02T00:00:00.000Z',
+ turns: [],
+ };
+ const siblingChat: ChatState = {
+ resource: 'ahp-chat:/sibling',
+ title: 'Sibling Chat',
+ status: SessionStatus.Idle,
+ modifiedAt: '2026-10-02T00:00:00.000Z',
+ turns: [],
+ };
+ const session: SessionState = {
+ provider: 'copilot',
+ title: 'Session',
+ status: SessionStatus.Idle | SessionStatus.IsRead,
+ lifecycle: SessionLifecycle.Ready,
+ activeClients: [],
+ chats: [defaultChat, siblingChat],
+ defaultChat: defaultChat.resource,
+ };
+
+ const updatedDefaultChat = chatReducer(defaultChat, {
+ type: ActionType.ChatIsReadChanged,
+ isRead: true,
+ });
+
+ assert.deepStrictEqual({
+ defaultChatStatus: updatedDefaultChat.status,
+ owningSessionStatus: session.status,
+ siblingChatStatus: siblingChat.status,
+ }, {
+ defaultChatStatus: SessionStatus.Idle | SessionStatus.IsRead,
+ owningSessionStatus: SessionStatus.Idle | SessionStatus.IsRead,
+ siblingChatStatus: SessionStatus.Idle,
+ });
+ });
+});
+
// ─── Immutability Checks ─────────────────────────────────────────────────────
//
// Verifying that the reducer does not mutate the input state requires
diff --git a/types/test-cases/reducers/286-chat-isreadchanged-marks-default-chat-as-read.json b/types/test-cases/reducers/286-chat-isreadchanged-marks-default-chat-as-read.json
new file mode 100644
index 000000000..e573f40c5
--- /dev/null
+++ b/types/test-cases/reducers/286-chat-isreadchanged-marks-default-chat-as-read.json
@@ -0,0 +1,24 @@
+{
+ "description": "chat/isReadChanged marks the addressed default chat as read",
+ "reducer": "chat",
+ "initial": {
+ "turns": [],
+ "resource": "ahp-chat://session/default",
+ "title": "Default Chat",
+ "status": 1,
+ "modifiedAt": "1970-01-01T00:00:01.000Z"
+ },
+ "actions": [
+ {
+ "type": "chat/isReadChanged",
+ "isRead": true
+ }
+ ],
+ "expected": {
+ "turns": [],
+ "resource": "ahp-chat://session/default",
+ "title": "Default Chat",
+ "status": 33,
+ "modifiedAt": "1970-01-01T00:00:01.000Z"
+ }
+}
diff --git a/types/test-cases/reducers/287-chat-isreadchanged-marks-chat-as-unread.json b/types/test-cases/reducers/287-chat-isreadchanged-marks-chat-as-unread.json
new file mode 100644
index 000000000..9bc9ff788
--- /dev/null
+++ b/types/test-cases/reducers/287-chat-isreadchanged-marks-chat-as-unread.json
@@ -0,0 +1,24 @@
+{
+ "description": "chat/isReadChanged marks the addressed sibling chat as unread",
+ "reducer": "chat",
+ "initial": {
+ "turns": [],
+ "resource": "ahp-chat://session/sibling",
+ "title": "Sibling Chat",
+ "status": 33,
+ "modifiedAt": "1970-01-01T00:00:01.000Z"
+ },
+ "actions": [
+ {
+ "type": "chat/isReadChanged",
+ "isRead": false
+ }
+ ],
+ "expected": {
+ "turns": [],
+ "resource": "ahp-chat://session/sibling",
+ "title": "Sibling Chat",
+ "status": 1,
+ "modifiedAt": "1970-01-01T00:00:01.000Z"
+ }
+}
diff --git a/types/test-cases/reducers/288-session-chatupdated-mirrors-chat-read-status.json b/types/test-cases/reducers/288-session-chatupdated-mirrors-chat-read-status.json
new file mode 100644
index 000000000..127fbd120
--- /dev/null
+++ b/types/test-cases/reducers/288-session-chatupdated-mirrors-chat-read-status.json
@@ -0,0 +1,57 @@
+{
+ "description": "session/chatUpdated mirrors chat/isReadChanged status into only the addressed chat catalog entry",
+ "reducer": "session",
+ "initial": {
+ "provider": "copilot",
+ "title": "Session",
+ "status": 33,
+ "lifecycle": "ready",
+ "activeClients": [],
+ "defaultChat": "ahp-chat:/default",
+ "chats": [
+ {
+ "resource": "ahp-chat:/default",
+ "title": "Default",
+ "status": 1,
+ "modifiedAt": "1970-01-01T00:00:01.000Z"
+ },
+ {
+ "resource": "ahp-chat:/sibling",
+ "title": "Sibling",
+ "status": 1,
+ "modifiedAt": "1970-01-01T00:00:01.000Z"
+ }
+ ]
+ },
+ "actions": [
+ {
+ "type": "session/chatUpdated",
+ "chat": "ahp-chat:/default",
+ "changes": {
+ "status": 33
+ }
+ }
+ ],
+ "expected": {
+ "provider": "copilot",
+ "title": "Session",
+ "status": 33,
+ "lifecycle": "ready",
+ "activeClients": [],
+ "defaultChat": "ahp-chat:/default",
+ "chats": [
+ {
+ "resource": "ahp-chat:/default",
+ "title": "Default",
+ "status": 33,
+ "modifiedAt": "1970-01-01T00:00:01.000Z"
+ },
+ {
+ "resource": "ahp-chat:/sibling",
+ "title": "Sibling",
+ "status": 1,
+ "modifiedAt": "1970-01-01T00:00:01.000Z"
+ }
+ ]
+ }
+}
diff --git a/types/test-cases/round-trips/019-session-chat-summary-interactivity-round-trip.json b/types/test-cases/round-trips/019-session-chat-summary-interactivity-round-trip.json
index d460046f2..69f149ca2 100644
--- a/types/test-cases/round-trips/019-session-chat-summary-interactivity-round-trip.json
+++ b/types/test-cases/round-trips/019-session-chat-summary-interactivity-round-trip.json
@@ -14,11 +14,13 @@
{
"resource": "ahp-chat:/lead",
"title": "Lead",
+ "status": 1,
"interactivity": "read-only"
},
{
"resource": "ahp-chat:/worker",
"title": "Worker",
+ "status": 1,
"interactivity": "hidden"
}
],
@@ -36,11 +38,13 @@
{
"resource": "ahp-chat:/lead",
"title": "Lead",
+ "status": 1,
"interactivity": "read-only"
},
{
"resource": "ahp-chat:/worker",
"title": "Worker",
+ "status": 1,
"interactivity": "hidden"
}
],
diff --git a/types/test-cases/round-trips/045-session-chat-summary-archived-round-trip.json b/types/test-cases/round-trips/045-session-chat-summary-archived-round-trip.json
index 6e2d0372c..2a0a8256e 100644
--- a/types/test-cases/round-trips/045-session-chat-summary-archived-round-trip.json
+++ b/types/test-cases/round-trips/045-session-chat-summary-archived-round-trip.json
@@ -1,7 +1,7 @@
{
"name": "session-chat-summary-archived-round-trip",
"group": "A",
- "description": "SessionSummary chat catalog entries preserve the optional archived flag so generic clients can group or filter archived chats without subscribing to the session channel.",
+ "description": "SessionSummary chat catalog entries preserve archived and read flags in the optional status bitset so generic clients can group or filter archived chats without subscribing to the session channel.",
"type": "SessionSummary",
"input": {
"resource": "ahp-session:/s1",
@@ -13,12 +13,13 @@
"chats": [
{
"resource": "ahp-chat:/lead",
- "title": "Lead"
+ "title": "Lead",
+ "status": 33
},
{
"resource": "ahp-chat:/worker",
"title": "Worker",
- "archived": true
+ "status": 97
}
],
"defaultChat": "ahp-chat:/lead"
@@ -34,12 +35,13 @@
"chats": [
{
"resource": "ahp-chat:/lead",
- "title": "Lead"
+ "title": "Lead",
+ "status": 33
},
{
"resource": "ahp-chat:/worker",
"title": "Worker",
- "archived": true
+ "status": 97
}
],
"defaultChat": "ahp-chat:/lead"
diff --git a/types/test-cases/round-trips/051-session-chat-summary-changes-round-trip.json b/types/test-cases/round-trips/051-session-chat-summary-changes-round-trip.json
index c7b811240..8cc8a59b9 100644
--- a/types/test-cases/round-trips/051-session-chat-summary-changes-round-trip.json
+++ b/types/test-cases/round-trips/051-session-chat-summary-changes-round-trip.json
@@ -18,11 +18,13 @@
"chats": [
{
"resource": "ahp-chat:/lead",
- "title": "Lead"
+ "title": "Lead",
+ "status": 1
},
{
"resource": "ahp-chat:/worker",
"title": "Worker",
+ "status": 1,
"changes": {
"additions": 12,
"deletions": 3,
@@ -48,11 +50,13 @@
"chats": [
{
"resource": "ahp-chat:/lead",
- "title": "Lead"
+ "title": "Lead",
+ "status": 1
},
{
"resource": "ahp-chat:/worker",
"title": "Worker",
+ "status": 1,
"changes": {
"additions": 12,
"deletions": 3,
diff --git a/types/test-cases/round-trips/053-session-chat-summary-read-state-round-trip.json b/types/test-cases/round-trips/053-session-chat-summary-read-state-round-trip.json
new file mode 100644
index 000000000..9aaf6c2ac
--- /dev/null
+++ b/types/test-cases/round-trips/053-session-chat-summary-read-state-round-trip.json
@@ -0,0 +1,68 @@
+{
+ "name": "session-chat-summary-read-state-round-trip",
+ "group": "A",
+ "description": "SessionSummary chat catalog entries preserve read, unread, activity, archived, and unknown future bits in the optional status bitset, while allowing hosts to omit unknown status.",
+ "type": "SessionSummary",
+ "input": {
+ "resource": "ahp-session:/s1",
+ "provider": "demo",
+ "title": "Agent team",
+ "status": 1,
+ "createdAt": "2024-01-01T00:00:00.001Z",
+ "modifiedAt": "2024-01-01T00:00:00.002Z",
+ "chats": [
+ {
+ "resource": "ahp-chat:/lead",
+ "title": "Lead",
+ "status": 33
+ },
+ {
+ "resource": "ahp-chat:/worker",
+ "title": "Worker",
+ "status": 24
+ },
+ {
+ "resource": "ahp-chat:/future",
+ "title": "Future",
+ "status": 225
+ },
+ {
+ "resource": "ahp-chat:/legacy",
+ "title": "Legacy"
+ }
+ ],
+ "defaultChat": "ahp-chat:/lead"
+ },
+ "acceptableOutputs": [
+ {
+ "resource": "ahp-session:/s1",
+ "provider": "demo",
+ "title": "Agent team",
+ "status": 1,
+ "createdAt": "2024-01-01T00:00:00.001Z",
+ "modifiedAt": "2024-01-01T00:00:00.002Z",
+ "chats": [
+ {
+ "resource": "ahp-chat:/lead",
+ "title": "Lead",
+ "status": 33
+ },
+ {
+ "resource": "ahp-chat:/worker",
+ "title": "Worker",
+ "status": 24
+ },
+ {
+ "resource": "ahp-chat:/future",
+ "title": "Future",
+ "status": 225
+ },
+ {
+ "resource": "ahp-chat:/legacy",
+ "title": "Legacy"
+ }
+ ],
+ "defaultChat": "ahp-chat:/lead"
+ }
+ ]
+}
diff --git a/types/version/registry.test.ts b/types/version/registry.test.ts
index 29862ed4b..93509bab3 100644
--- a/types/version/registry.test.ts
+++ b/types/version/registry.test.ts
@@ -71,6 +71,17 @@ test('chat/turnResume is available starting in protocol 0.9.0', () => {
assert.equal(isActionKnownToVersion(action, '0.9.0'), true);
});
+test('chat/isReadChanged is available starting in protocol 0.9.0', () => {
+ const action = {
+ type: ActionType.ChatIsReadChanged,
+ isRead: true,
+ } as const;
+
+ assert.equal(ACTION_INTRODUCED_IN[ActionType.ChatIsReadChanged], '0.9.0');
+ assert.equal(isActionKnownToVersion(action, '0.8.0'), false);
+ assert.equal(isActionKnownToVersion(action, '0.9.0'), true);
+});
+
test('public package entry re-exports both protocol-version constants', async () => {
const pkg = await import('../index.js');
assert.equal(pkg.PROTOCOL_VERSION, PROTOCOL_VERSION);
diff --git a/types/version/registry.ts b/types/version/registry.ts
index 3709de851..6da36441e 100644
--- a/types/version/registry.ts
+++ b/types/version/registry.ts
@@ -141,6 +141,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string
[ActionType.ChatPendingMessageRemoved]: '0.4.0',
[ActionType.ChatQueuedMessagesReordered]: '0.4.0',
[ActionType.ChatDraftChanged]: '0.5.0',
+ [ActionType.ChatIsReadChanged]: '0.9.0',
[ActionType.ChatIsArchivedChanged]: '0.9.0',
[ActionType.ChatInputRequested]: '0.4.0',
[ActionType.ChatInputAnswerChanged]: '0.4.0',